Merge branch 'devel' into java.time

remotes/Isira-Seneviratne/java.time
Isira Seneviratne 2 years ago
commit 6eef279861

1
.gitignore vendored

@ -15,6 +15,7 @@
/.idea/kotlinc.xml /.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml
/.idea/inspectionProfiles/ /.idea/inspectionProfiles/
.DS_Store .DS_Store

@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 602 versionCode = 608
versionName = '6.4.2' versionName = '6.5.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:0efd5437f9') { implementation('com.github.KotatsuApp:kotatsu-parsers:4a0e7221b0') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -92,10 +92,9 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.1' implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
// implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
@ -104,7 +103,7 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.10.0' implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
implementation 'androidx.work:work-runtime:2.9.0' implementation 'androidx.work:work-runtime:2.9.0'
@ -121,19 +120,19 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.6.0' implementation 'com.squareup.okio:okio:3.7.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.48.1' implementation 'com.google.dagger:hilt-android:2.50'
kapt 'com.google.dagger:hilt-compiler:2.48.1' kapt 'com.google.dagger:hilt-compiler:2.50'
implementation 'androidx.hilt:hilt-work:1.1.0' implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:771c8753ae' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@ -156,6 +155,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
} }

@ -61,13 +61,20 @@ class CaptchaNotifier(
override fun onError(request: ImageRequest, result: ErrorResult) { override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result) super.onError(request, result)
val e = result.throwable val e = result.throwable
if (e is CloudFlareProtectedException) { if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
notify(e) notify(e)
} }
} }
private companion object { companion object {
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
key = PARAM_IGNORE_CAPTCHA,
value = true,
memoryCacheKey = null,
)
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha" private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID private const val TAG = CHANNEL_ID
} }

@ -11,6 +11,7 @@ import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
@ -39,7 +40,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
@Inject @Inject
lateinit var database: MangaDatabase lateinit var database: Provider<MangaDatabase>
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@ -51,7 +52,7 @@ open class BaseApp : Application(), Configuration.Provider {
lateinit var appValidator: AppValidator lateinit var appValidator: AppValidator
@Inject @Inject
lateinit var workScheduleManager: WorkScheduleManager lateinit var workScheduleManager: Provider<WorkScheduleManager>
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
@ -63,14 +64,19 @@ open class BaseApp : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales) AppCompatDelegate.setApplicationLocales(settings.appLocales)
setupActivityLifecycleCallbacks() setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
}
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers() setupDatabaseObservers()
} }
workScheduleManager.init() workScheduleManager.get().init()
WorkServiceStopHelper(workManagerProvider).setup() WorkServiceStopHelper(workManagerProvider).setup()
} }
@ -79,13 +85,6 @@ open class BaseApp : Application(), Configuration.Provider {
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login) basicAuthLogin = getString(R.string.acra_login)
@ -102,7 +101,6 @@ open class BaseApp : Application(), Configuration.Provider {
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA, ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@ -117,7 +115,7 @@ open class BaseApp : Application(), Configuration.Provider {
@WorkerThread @WorkerThread
private fun setupDatabaseObservers() { private fun setupDatabaseObservers() {
val tracker = database.invalidationTracker val tracker = database.get().invalidationTracker
databaseObservers.forEach { databaseObservers.forEach {
tracker.addObserver(it) tracker.addObserver(it)
} }

@ -3,17 +3,20 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
class BackupEntry( class BackupEntry(
val name: String, val name: Name,
val data: JSONArray val data: JSONArray
) { ) {
companion object Names { enum class Name(
val key: String,
) {
const val INDEX = "index" INDEX("index"),
const val HISTORY = "history" HISTORY("history"),
const val CATEGORIES = "categories" CATEGORIES("categories"),
const val FAVOURITES = "favourites" FAVOURITES("favourites"),
const val SETTINGS = "settings" SETTINGS("settings"),
const val BOOKMARKS = "bookmarks" BOOKMARKS("bookmarks"),
SOURCES("sources"),
} }
} }

@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@ -20,7 +22,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpHistory(): BackupEntry { suspend fun dumpHistory(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
@ -41,7 +43,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll() val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
@ -51,7 +53,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpFavourites(): BackupEntry { suspend fun dumpFavourites(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
@ -72,7 +74,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
@ -90,7 +92,7 @@ class BackupRepository @Inject constructor(
} }
fun dumpSettings(): BackupEntry { fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap() val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
@ -101,8 +103,18 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry { fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject() val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE) json.put("app_version", BuildConfig.VERSION_CODE)
@ -111,6 +123,11 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
@ -184,6 +201,17 @@ class BackupRepository @Inject constructor(
return result return result
} }
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {

@ -1,25 +1,44 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use { val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText()) JSONArray(it.bufferedReader().readText())
} }
BackupEntry(name, json) BackupEntry(name, json)
} }
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() { override fun close() {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
} }

@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2)) output.put(entry.name.key, entry.data.toString(2))
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { suspend fun finish() = runInterruptible(Dispatchers.IO) {

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@ -78,6 +79,12 @@ class JsonDeserializer(private val json: JSONObject) {
percent = json.getDouble("percent").toFloat(), percent = json.getDouble("percent").toFloat(),
) )
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>() val map = mutableMapOf<String, Any?>()
val keys = json.keys() val keys = json.keys()

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@ -82,6 +83,14 @@ class JsonSerializer private constructor(private val json: JSONObject) {
}, },
) )
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this( constructor(m: Map<String, *>) : this(
JSONObject(m), JSONObject(m),
) )

@ -12,7 +12,7 @@ class ExpiringLruCache<T>(
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? { operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
} }

@ -29,6 +29,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@ -53,7 +54,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 17 const val DATABASE_VERSION = 18
@Database( @Database(
entities = [ entities = [
@ -108,6 +109,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration14To15(), Migration14To15(),
Migration15To16(), Migration15To16(),
Migration16To17(context), Migration16To17(context),
Migration17To18(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -23,6 +24,10 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE public_url = :publicUrl") @Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Transaction @Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@ -43,6 +48,10 @@ abstract class MangaDao {
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
abstract suspend fun clearTagRelation(mangaId: Long) abstract suspend fun clearTagRelation(mangaId: Long)
@Transaction
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Transaction @Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) { open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga) upsert(manga)

@ -24,4 +24,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
) )

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration17To18 : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
}
}

@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
class CompositeException(val errors: Collection<Throwable>) : Exception() {
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
}

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -43,6 +44,15 @@ val MangaState.titleResId: Int
MangaState.PAUSED -> R.string.state_paused MangaState.PAUSED -> R.string.state_paused
} }
@get:DrawableRes
val MangaState.iconResId: Int
get() = when (this) {
MangaState.ONGOING -> R.drawable.ic_state_ongoing
MangaState.FINISHED -> R.drawable.ic_state_finished
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
MangaState.PAUSED -> R.drawable.ic_action_pause
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

@ -1,17 +1,23 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.content.Context import android.content.Context
import android.graphics.Color
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
fun MangaSource(name: String): MangaSource { fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach { MangaSource.entries.forEach {
@ -33,6 +39,24 @@ val ContentType.titleResId
fun MangaSource.getSummary(context: Context): String { fun MangaSource.getSummary(context: Context): String {
val type = context.getString(contentType.titleResId) val type = context.getString(contentType.titleResId)
val locale = getLocaleTitle() ?: context.getString(R.string.various_languages) val locale = locale?.toLocale().getDisplayName(context)
return context.getString(R.string.source_summary_pattern, type, locale) return context.getString(R.string.source_summary_pattern, type, locale)
} }
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
}
} else {
title
}
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {
append(context.getString(R.string.nsfw))
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.core.net.toUri
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -13,6 +14,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@ -97,9 +99,18 @@ class MangaDataRepository @Inject constructor(
return db.getTagsDao().findTags(source.name).toMangaTags() return db.getTagsDao().findTags(source.name).toMangaTags()
} }
suspend fun cleanupLocalManga() {
val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga })
}
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert) ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
} else { } else {
null null
} }
@ -111,5 +122,6 @@ class MangaDataRepository @Inject constructor(
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false, cfInvert = false,
cfGrayscale = false,
) )
} }

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@ -41,6 +42,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
@Singleton @Singleton

@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@ -104,6 +105,10 @@ class RemoteMangaRepository(
parser.getAvailableTags() parser.getAvailableTags()
} }
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons() parser.getFavicons()
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
@ -14,16 +13,12 @@ import androidx.core.content.edit
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@ -301,22 +296,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var readerColorFilter: ReaderColorFilter? var readerColorFilter: ReaderColorFilter?
get() { get() {
if (!prefs.getBoolean(KEY_CF_ENABLED, false)) { val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
return null val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
} val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, 0f) val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
val contrast = prefs.getFloat(KEY_CF_CONTRAST, 0f) return ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
val inverted = prefs.getBoolean(KEY_CF_INVERTED, false)
return ReaderColorFilter(brightness, contrast, inverted)
} }
set(value) { set(value) {
prefs.edit { prefs.edit {
putBoolean(KEY_CF_ENABLED, value != null) val cf = value ?: ReaderColorFilter.EMPTY
if (value != null) { putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
putFloat(KEY_CF_BRIGHTNESS, value.brightness) putFloat(KEY_CF_CONTRAST, cf.contrast)
putFloat(KEY_CF_CONTRAST, value.contrast) putBoolean(KEY_CF_INVERTED, cf.isInverted)
putBoolean(KEY_CF_INVERTED, value.isInverted) putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
}
} }
} }
@ -452,17 +444,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return result return result
} }
@SuppressLint("ApplySharedPref")
private inline fun SharedPreferences.editAsync(
action: SharedPreferences.Editor.() -> Unit
) {
val editor = edit()
action(editor)
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
editor.commit()
}
}
companion object { companion object {
const val PAGE_SWITCH_TAPS = "taps" const val PAGE_SWITCH_TAPS = "taps"
@ -573,10 +554,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog" const val KEY_SOURCES_CATALOG = "sources_catalog"
const val KEY_CF_ENABLED = "cf_enabled"
const val KEY_CF_BRIGHTNESS = "cf_brightness" const val KEY_CF_BRIGHTNESS = "cf_brightness"
const val KEY_CF_CONTRAST = "cf_contrast" const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -0,0 +1,75 @@
package org.koitharu.kotatsu.core.ui
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections
import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
private val listListeners = LinkedList<ListListener<T>>()
override suspend fun emit(value: List<T>?) {
val oldList = items.orEmpty()
val newList = value.orEmpty()
val diffResult = withContext(Dispatchers.Default) {
val diffCallback = DiffCallback(oldList, newList)
DiffUtil.calculateDiff(diffCallback)
}
super.setItems(newList)
diffResult.dispatchUpdatesTo(this)
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
}
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
override fun setItems(items: List<T>?) {
super.setItems(items)
}
fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos)
notifyItemMoved(oldPos, newPos)
}
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
delegatesManager.addDelegate(type.ordinal, delegate)
return this
}
fun addListListener(listListener: ListListener<T>) {
listListeners.add(listListener)
}
fun removeListListener(listListener: ListListener<T>) {
listListeners.remove(listListener)
}
protected class DiffCallback<T : ListModel>(
val oldList: List<T>,
val newList: List<T>,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem.areItemsTheSame(oldItem)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem == oldItem
}
}
}

@ -1,100 +0,0 @@
package org.koitharu.kotatsu.core.ui.drawable
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.graphics.withTranslation
import com.google.android.material.resources.TextAppearance
import com.google.android.material.resources.TextAppearanceFontCallback
import org.koitharu.kotatsu.core.util.ext.getThemeColor
class TextDrawable(
val text: CharSequence,
) : Drawable() {
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private var cachedLayout: StaticLayout? = null
@SuppressLint("RestrictedApi")
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
val ta = TextAppearance(context, textAppearanceId)
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
paint.typeface = ta.fallbackFont
ta.getFontAsync(
context, paint,
object : TextAppearanceFontCallback() {
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
override fun onFontRetrievalFailed(reason: Int) = Unit
},
)
paint.letterSpacing = ta.letterSpacing
}
var alignment = Layout.Alignment.ALIGN_NORMAL
var lineSpacingMultiplier = 1f
@Px
var lineSpacingExtra = 0f
@get:ColorInt
var textColor: Int
get() = paint.color
set(@ColorInt value) {
paint.color = value
}
override fun draw(canvas: Canvas) {
val b = bounds
if (b.isEmpty) {
return
}
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
obtainLayout().draw(canvas)
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.setColorFilter(colorFilter)
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun obtainLayout(): StaticLayout {
val width = bounds.width()
cachedLayout?.let {
if (it.width == width) {
return it
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(alignment)
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
.setIncludePad(true)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
}.also { cachedLayout = it }
}
}

@ -1,64 +0,0 @@
package org.koitharu.kotatsu.core.ui.list
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.core.os.BundleCompat
import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import java.util.WeakHashMap
class NestedScrollStateHandle(
savedInstanceState: Bundle?,
private val key: String,
) {
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
} ?: SparseArray<Parcelable?>()
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
fun onSaveInstanceState(outState: Bundle) {
controllers.forEach {
it.saveState()
}
outState.putSparseParcelableArray(key, storage)
}
inner class Controller(
private val recycler: RecyclerView
) {
private var lastPosition: Int = -1
fun onBind(position: Int) {
if (position != lastPosition) {
saveState()
lastPosition = position
storage[position]?.let {
restoreState(it)
}
}
}
fun onRecycled() {
saveState()
lastPosition = -1
}
fun saveState() {
if (lastPosition != -1) {
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
}
}
private fun restoreState(state: Parcelable) {
recycler.doOnNextLayout {
recycler.layoutManager?.onRestoreInstanceState(state)
}
}
}
}

@ -1,237 +1,4 @@
package org.koitharu.kotatsu.core.ui.list package org.koitharu.kotatsu.core.ui.list
import android.app.Activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import kotlin.coroutines.EmptyCoroutineContext
private const val PROVIDER_NAME = "selection_decoration_sectioned" private const val PROVIDER_NAME = "selection_decoration_sectioned"
class SectionedSelectionController<T : Any>(
private val activity: Activity,
private val owner: SavedStateRegistryOwner,
private val callback: Callback<T>,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
private var pendingData: MutableMap<String, Collection<Long>>? = null
private val decorations = ArrayMap<T, AbstractSelectionItemDecoration>()
val count: Int
get() = decorations.values.sumOf { it.checkedItemsCount }
init {
owner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds.toSet() }
}
fun peekCheckedIds(): Map<T, Set<Long>> {
return decorations.mapValues { it.value.checkedItemsIds }
}
fun clear() {
decorations.values.forEach {
it.clearSelection()
}
notifySelectionChanged()
}
fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
val decoration = getDecoration(section)
val pendingIds = pendingData?.remove(section.toString())
if (!pendingIds.isNullOrEmpty()) {
decoration.checkAll(pendingIds)
startActionMode()
notifySelectionChanged()
}
var shouldAddDecoration = true
for (i in (0 until recyclerView.itemDecorationCount).reversed()) {
val decor = recyclerView.getItemDecorationAt(i)
if (decor === decoration) {
shouldAddDecoration = false
break
} else if (decor.javaClass == decoration.javaClass) {
recyclerView.removeItemDecorationAt(i)
}
}
if (shouldAddDecoration) {
recyclerView.addItemDecoration(decoration)
}
if (pendingData?.isEmpty() == true) {
pendingData = null
}
}
override fun saveState(): Bundle {
val bundle = Bundle(decorations.size)
for ((k, v) in decorations) {
bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
}
return bundle
}
fun onItemClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
if (isInSelectionMode()) {
decoration.toggleItemChecked(id)
if (isInSelectionMode()) {
actionMode?.invalidate()
} else {
actionMode?.finish()
}
notifySelectionChanged()
return true
}
return false
}
fun onItemLongClick(section: T, id: Long): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
}
fun getSectionCount(section: T): Int {
return decorations[section]?.checkedItemsCount ?: 0
}
fun addToSelection(section: T, ids: Collection<Long>): Boolean {
val decoration = getDecoration(section)
startActionMode()
return actionMode?.also {
decoration.checkAll(ids)
notifySelectionChanged()
} != null
}
fun clearSelection(section: T) {
decorations[section]?.clearSelection() ?: return
notifySelectionChanged()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onCreateActionMode(this, mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onPrepareActionMode(this, mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return callback.onActionItemClicked(this, mode, item)
}
override fun onDestroyActionMode(mode: ActionMode) {
callback.onDestroyActionMode(this, mode)
clear()
actionMode = null
}
private fun startActionMode() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun isInSelectionMode(): Boolean {
return decorations.values.any { x -> x.checkedItemsCount > 0 }
}
private fun notifySelectionChanged() {
val count = this.count
callback.onSelectionChanged(this, count)
if (count == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
private fun restoreState(ids: MutableMap<String, Collection<Long>>) {
if (ids.isEmpty() || isInSelectionMode()) {
return
}
for ((k, v) in decorations) {
val items = ids.remove(k.toString())
if (!items.isNullOrEmpty()) {
v.checkAll(items)
}
}
pendingData = ids
if (isInSelectionMode()) {
startActionMode()
notifySelectionChanged()
}
}
private fun getDecoration(section: T): AbstractSelectionItemDecoration {
return decorations.getOrPut(section) {
callback.onCreateItemDecoration(this, section)
}
}
interface Callback<T : Any> {
fun onSelectionChanged(controller: SectionedSelectionController<T>, count: Int)
fun onCreateActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean
fun onPrepareActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean {
mode.title = controller.count.toString()
return true
}
fun onDestroyActionMode(controller: SectionedSelectionController<T>, mode: ActionMode) = Unit
fun onActionItemClicked(
controller: SectionedSelectionController<T>,
mode: ActionMode,
item: MenuItem,
): Boolean
fun onCreateItemDecoration(
controller: SectionedSelectionController<T>,
section: T,
): AbstractSelectionItemDecoration
}
private inner class StateEventObserver : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) {
val registry = owner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
if (state != null) {
Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
restoreState(
state.keySet()
.associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
)
}
}
}
}
}
}
}

@ -12,7 +12,12 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.* import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.annotation.StyleableRes
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@ -131,19 +136,19 @@ class FastScroller @JvmOverloads constructor(
var showTrack = false var showTrack = false
context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) {
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor)
handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor) handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor)
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor) trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor)
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor) textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor)
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar) hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar)
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble) showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways) showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack) showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset) offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
} }
setTrackColor(trackColor) setTrackColor(trackColor)

@ -70,7 +70,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis
lastInsets = null lastInsets = null
} }
interface WindowInsetsListener { fun interface WindowInsetsListener {
fun onWindowInsetsChanged(insets: Insets) fun onWindowInsetsChanged(insets: Insets)
} }

@ -1,21 +1,16 @@
package org.koitharu.kotatsu.core.ui.widgets package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
@ -31,9 +26,7 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
} }
private val defaultChipStrokeColor: ColorStateList private val chipStyle: Int
private val defaultChipTextColor: ColorStateList
private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
set(value) { set(value) {
field = value field = value
@ -48,12 +41,17 @@ class ChipsView @JvmOverloads constructor(
} }
init { init {
@SuppressLint("CustomViewStyleable") val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor) ta.recycle()
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint) if (isInEditMode) {
a.recycle() setChips(
List(5) {
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
},
)
}
} }
override fun requestLayout() { override fun requestLayout() {
@ -91,15 +89,6 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) { private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title chip.text = model.title
val tint = if (model.tint == 0) {
null
} else {
ContextCompat.getColorStateList(context, model.tint)
}
chip.chipIconTint = tint ?: defaultChipIconTint
chip.checkedIconTint = tint ?: defaultChipIconTint
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
if (model.icon == 0) { if (model.icon == 0) {
@ -115,12 +104,11 @@ class ChipsView @JvmOverloads constructor(
private fun addChip(): Chip { private fun addChip(): Chip {
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check) chip.setCheckedIconResource(R.drawable.ic_check)
chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)

@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewTipBinding import org.koitharu.kotatsu.databinding.ViewTipBinding
import com.google.android.material.R as materialR
class TipView @JvmOverloads constructor( class TipView @JvmOverloads constructor(
context: Context, context: Context,

@ -4,23 +4,20 @@ import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.map
import java.util.Locale import java.util.Locale
class LocaleComparator : Comparator<Locale?> { class LocaleComparator : Comparator<Locale> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context) private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
.map { it.language } .map { it.language }
.distinct() .distinct()
override fun compare(a: Locale?, b: Locale?): Int { override fun compare(a: Locale, b: Locale): Int {
return if (a === b) { val indexA = deviceLocales.indexOf(a.language)
0 val indexB = deviceLocales.indexOf(b.language)
} else { return when {
val indexA = if (a == null) -1 else deviceLocales.indexOf(a.language) indexA < 0 && indexB < 0 -> compareValues(a.language, b.language)
val indexB = if (b == null) -1 else deviceLocales.indexOf(b.language) indexA < 0 -> 1
if (indexA < 0 && indexB < 0) { indexB < 0 -> -1
compareValues(a?.language, b?.language) else -> compareValues(indexA, indexB)
} else {
-2 - (indexA - indexB)
}
} }
} }
} }

@ -21,7 +21,11 @@ class ViewBadge(
get() = badgeDrawable?.number ?: 0 get() = badgeDrawable?.number ?: 0
set(value) { set(value) {
val badge = badgeDrawable ?: initBadge() val badge = badgeDrawable ?: initBadge()
if (maxCharacterCount != 0) {
badge.number = value badge.number = value
} else {
badge.clearNumber()
}
badge.isVisible = value > 0 badge.isVisible = value > 0
} }
@ -51,7 +55,13 @@ class ViewBadge(
fun setMaxCharacterCount(value: Int) { fun setMaxCharacterCount(value: Int) {
maxCharacterCount = value maxCharacterCount = value
badgeDrawable?.maxCharacterCount = value badgeDrawable?.let {
if (value == 0) {
it.clearNumber()
} else {
it.maxCharacterCount = value
}
}
} }
private fun initBadge(): BadgeDrawable { private fun initBadge(): BadgeDrawable {

@ -130,7 +130,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
} else { } else {
// Set navbar scrim 70% of navigationBarColor // Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded( ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(android.R.attr.navigationBarColor, alphaFactor), context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor),
elevation, elevation,
) )
} }

@ -50,7 +50,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
val length = ArrayReflect.getLength(checkNotNull(result)) val length = ArrayReflect.getLength(checkNotNull(result))
(0 until length).firstNotNullOfOrNull { i -> (0 until length).firstNotNullOfOrNull { i ->
val storageVolumeElement = ArrayReflect.get(result, i) val storageVolumeElement = ArrayReflect.get(result, i)
val uuid = getUuid.invoke(storageVolumeElement) as String val uuid = getUuid.invoke(storageVolumeElement) as String?
val primary = isPrimary.invoke(storageVolumeElement) as Boolean val primary = isPrimary.invoke(storageVolumeElement) as Boolean
when { when {
primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String

@ -20,12 +20,13 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
fun String?.getLocaleDisplayName(context: Context): String { fun String.toLocale() = Locale(this)
fun Locale?.getDisplayName(context: Context): String {
if (this == null) { if (this == null) {
return context.getString(R.string.various_languages) return context.getString(R.string.various_languages)
} }
val lc = Locale(this) return getDisplayLanguage(this).toTitleCase(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
} }
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> { private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {

@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
@ -28,9 +33,6 @@ import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
@ -85,9 +87,11 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null else -> null
} }

@ -104,6 +104,7 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup? val View.parentView: ViewGroup?
get() = parent as? ViewGroup get() = parent as? ViewGroup
@Suppress("UnusedReceiverParameter")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int var result: Int
val specMode = MeasureSpec.getMode(measureSpec) val specMode = MeasureSpec.getMode(measureSpec)

@ -1,16 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
open class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

@ -91,7 +91,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent) tryStart(context, intent)
} }
fun prefetchPages(context: Context, chapter: MangaChapter) { fun prefetchPages(context: Context, chapter: MangaChapter) {
@ -99,19 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try { tryStart(context, intent)
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST intent.action = ACTION_PREFETCH_LAST
context.startService(intent) tryStart(context, intent)
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
@ -127,5 +122,14 @@ class MangaPrefetchService : CoroutineIntentService() {
) )
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
private fun tryStart(context: Context, intent: Intent) {
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
}
} }
} }

@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration

@ -53,6 +53,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
@ -134,8 +135,14 @@ class DetailsViewModel @Inject constructor(
.map { it?.local } .map { it?.local }
.distinctUntilChanged() .distinctUntilChanged()
.map { local -> .map { local ->
local?.file?.computeSize() ?: 0L if (local != null) {
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) runCatchingCancellable {
local.file.computeSize()
}.getOrDefault(0L)
} else {
0L
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L)
@Deprecated("") @Deprecated("")
val description = details val description = details

@ -1,39 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.text.Spannable
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.details.ui.model.MangaBranch
fun branchAD(
clickListener: OnListItemClickListener<MangaBranch>,
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickAdapter)
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
val counterSizeSpan = RelativeSizeSpan(0.86f)
bind {
binding.root.text = buildSpannedString {
append(item.name ?: getString(R.string.system_default))
append(' ')
append(' ')
val start = length
append(item.count.toString())
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.root.isChecked = item.isSelected
}
}

@ -6,7 +6,6 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible

@ -5,7 +5,9 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult import coil.request.SuccessResult
@ -59,6 +61,7 @@ fun downloadItemAD(
val chaptersAdapter = BaseListAdapter<DownloadChapter>() val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD()) .addDelegate(ListItemType.CHAPTER, downloadChapterAD())
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter binding.recyclerViewChapters.adapter = chaptersAdapter
binding.buttonCancel.setOnClickListener(clickListener) binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener)

@ -21,7 +21,8 @@ class PausingReceiver(
return return
} }
when (intent.action) { when (intent.action) {
ACTION_RESUME -> pausingHandle.resume(intent.getBooleanExtra(EXTRA_SKIP_ERROR, false)) ACTION_RESUME -> pausingHandle.resume(skipError = false)
ACTION_SKIP -> pausingHandle.resume(skipError = true)
ACTION_PAUSE -> pausingHandle.pause() ACTION_PAUSE -> pausingHandle.pause()
} }
} }
@ -30,13 +31,14 @@ class PausingReceiver(
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
private const val EXTRA_UUID = "uuid" private const val EXTRA_UUID = "uuid"
private const val EXTRA_SKIP_ERROR = "skip"
private const val SCHEME = "workuid" private const val SCHEME = "workuid"
fun createIntentFilter(id: UUID) = IntentFilter().apply { fun createIntentFilter(id: UUID) = IntentFilter().apply {
addAction(ACTION_PAUSE) addAction(ACTION_PAUSE)
addAction(ACTION_RESUME) addAction(ACTION_RESUME)
addAction(ACTION_SKIP)
addDataScheme(SCHEME) addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
} }
@ -46,11 +48,11 @@ class PausingReceiver(
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(ACTION_RESUME) fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
.setData(Uri.parse("$SCHEME://$id")) if (skipError) ACTION_SKIP else ACTION_RESUME,
).setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName) .setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) .putExtra(EXTRA_UUID, id.toString())
.putExtra(EXTRA_SKIP_ERROR, skipError)
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context, context,

@ -111,7 +111,7 @@ class ExploreFragment :
} }
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(Intent(view.context, SourcesCatalogActivity::class.java)) startActivity(SettingsActivity.newManageSourcesIntent(view.context))
} }
override fun onPrimaryButtonClick(tipView: TipView) { override fun onPrimaryButtonClick(tipView: TipView) {
@ -160,7 +160,7 @@ class ExploreFragment :
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(SettingsActivity.newManageSourcesIntent(context ?: return)) startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java))
} }
private fun onOpenManga(manga: Manga) { private fun onOpenManga(manga: Manga) {

@ -21,7 +21,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
@ -56,8 +55,6 @@ class ExploreViewModel @Inject constructor(
valueProducer = { isSuggestionsEnabled }, valueProducer = { isSuggestionsEnabled },
) )
val sortOrder = MutableStateFlow(SourcesSortOrder.MANUAL) // TODO
val onOpenManga = MutableEventFlow<Manga>() val onOpenManga = MutableEventFlow<Manga>()
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowSuggestionsTip = MutableEventFlow<Unit>() val onShowSuggestionsTip = MutableEventFlow<Unit>()
@ -136,7 +133,7 @@ class ExploreViewModel @Inject constructor(
result += RecommendationsItem(recommendation) result += RecommendationsItem(recommendation)
} }
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
result += ListHeader(R.string.remote_sources, R.string.catalog) result += ListHeader(R.string.remote_sources, R.string.manage)
if (newSources.isNotEmpty()) { if (newSources.isNotEmpty()) {
result += TipModel( result += TipModel(
key = TIP_NEW_SOURCES, key = TIP_NEW_SOURCES,
@ -153,7 +150,7 @@ class ExploreViewModel @Inject constructor(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,
textPrimary = R.string.no_manga_sources, textPrimary = R.string.no_manga_sources,
textSecondary = R.string.no_manga_sources_text, textSecondary = R.string.no_manga_sources_text,
actionStringRes = R.string.manage, actionStringRes = R.string.catalog,
) )
} }
return result return result

@ -8,6 +8,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getSummary 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.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
@ -112,7 +113,7 @@ fun exploreSourceListItemAD(
binding.root.setOnContextClickListenerCompat(eventListener) binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.getTitle(context)
binding.textViewSubtitle.text = item.source.getSummary(context) binding.textViewSubtitle.text = item.source.getSummary(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
@ -147,7 +148,7 @@ fun exploreSourceGridItemAD(
binding.root.setOnContextClickListenerCompat(eventListener) binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.getTitle(context)
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
fallback(fallbackIcon) fallback(fallbackIcon)

@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
} }
val fromPos = viewHolder.bindingAdapterPosition val fromPos = viewHolder.bindingAdapterPosition
val toPos = target.bindingAdapterPosition val toPos = target.bindingAdapterPosition
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
return false
}
adapter.reorderItems(fromPos, toPos)
return true
} }
override fun canDropOver( override fun canDropOver(
@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType ): Boolean = current.itemViewType == target.itemViewType
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
viewBinding.recyclerView.isNestedScrollingEnabled = viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
actionState == ItemTouchHelper.ACTION_STATE_IDLE }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewModel.saveOrder(adapter.items ?: return)
} }
} }

@ -1,13 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.yield import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.move
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null private var commitJob: Job? = null
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) val content = repository.observeCategoriesWithCovers()
.map { it.toUiList() }
init { .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
commitJob?.join()
updateContent(it)
}
}
}
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) { fun saveOrder(snapshot: List<ListModel>) {
val snapshot = content.requireValue().toMutableList() val prevJob = commitJob
snapshot.move(oldPos, newPos) commitJob = launchJob {
content.value = snapshot prevJob?.cancelAndJoin()
commit(snapshot) val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
if (ids.isNotEmpty()) {
repository.reorderCategories(ids)
}
}
} }
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) { fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@ -76,21 +74,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
private fun commit(snapshot: List<ListModel>) { private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
yield()
}
}
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
content.value = categories.map { (category, covers) ->
CategoryListModel( CategoryListModel(
mangaCount = covers.size, mangaCount = covers.size,
covers = covers.take(3), covers = covers.take(3),
@ -107,5 +91,4 @@ class FavouritesCategoriesViewModel @Inject constructor(
), ),
) )
} }
}
} }

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@ -15,7 +15,7 @@ class CategoriesAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
onItemClickListener: FavouriteCategoriesListListener, onItemClickListener: FavouriteCategoriesListListener,
listListener: ListStateHolderListener, listListener: ListStateHolderListener,
) : BaseListAdapter<ListModel>() { ) : ReorderableListAdapter<ListModel>() {
init { init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))

@ -65,10 +65,7 @@ fun categoryAD(
binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewEdit.setOnClickListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads -> bind {
if (payloads.isNotEmpty()) {
return@bind
}
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.textViewSubtitle.text = if (item.mangaCount == 0) { binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty) getString(R.string.empty)

@ -15,13 +15,13 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, List
val onClickListener = View.OnClickListener { v -> val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context) R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context) R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context)
else -> return@OnClickListener else -> return@OnClickListener
} }
v.context.startActivity(intent) v.context.startActivity(intent)
} }
binding.buttonCreate.setOnClickListener(onClickListener) binding.chipCreate.setOnClickListener(onClickListener)
binding.buttonManage.setOnClickListener(onClickListener) binding.chipManage.setOnClickListener(onClickListener)
} }

@ -20,7 +20,7 @@ fun mangaCategoryAD(
} }
bind { payloads -> bind { payloads ->
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary

@ -1,41 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.content.Context
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterAdapter(
listener: OnFilterChangedListener,
listListener: ListListener<ListModel>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.FOOTER_ERROR, filterErrorDelegate())
differ.addListListener(listListener)
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is FilterItem.Tag) {
return item.tag.title.firstOrNull()?.toString()
}
}
return null
}
}

@ -1,86 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
fun filterSortDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Sort, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onSortItemClick(item)
}
bind { payloads ->
binding.root.setText(item.order.titleRes)
binding.root.setChecked(item.isSelected, payloads.isNotEmpty())
}
}
fun filterStateDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onStateItemClick(item)
}
bind { payloads ->
binding.root.setText(item.state.titleResId)
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item, isFromChip = false)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagMultipleDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item, isFromChip = false)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, ListModel>(R.layout.item_sources_empty) {
bind {
(itemView as TextView).setText(item.textResId)
}
}

@ -1,39 +1,50 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import android.view.View import android.view.View
import androidx.annotation.WorkerThread
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.ErrorFooter
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@ -55,16 +66,84 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = private val currentState = MutableStateFlow(
MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet())) MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()),
private var searchQuery = MutableStateFlow("") )
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync()
private var allTagsLoadJob: Job? = null
override val allTags = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
get() {
if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) {
loadAllTags()
}
return field
}
override val filterTags: StateFlow<FilterProperty<MangaTag>> = combine(
currentState.distinctUntilChangedBy { it.tags },
getTopTagsAsFlow(currentState.map { it.tags }, 16),
) { state, tags ->
FilterProperty(
availableItems = tags.items.asArrayList(),
selectedItems = state.tags,
isLoading = tags.isLoading,
error = tags.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterItems: StateFlow<List<ListModel>> = getItemsFlow() override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine(
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) currentState.distinctUntilChangedBy { it.sortOrder },
flowOf(repository.sortOrders),
) { state, orders ->
FilterProperty(
availableItems = orders.sortedBy { it.ordinal },
selectedItems = setOf(state.sortOrder),
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterState: StateFlow<FilterProperty<MangaState>> = combine(
currentState.distinctUntilChangedBy { it.states },
flowOf(repository.states),
) { state, states ->
FilterProperty(
availableItems = states.sortedBy { it.ordinal },
selectedItems = state.states,
isLoading = false,
error = null,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
currentState.distinctUntilChangedBy { it.locale },
getLocalesAsFlow(),
) { state, locales ->
val list = if (locales.items.isNotEmpty()) {
val l = ArrayList<Locale?>(locales.items.size + 1)
l.add(null)
l.addAll(locales.items)
try {
l.sortWith(nullsFirst(LocaleComparator()))
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
}
l
} else {
emptyList()
}
FilterProperty(
availableItems = list,
selectedItems = setOf(state.locale),
isLoading = locales.isLoading,
error = locales.error,
)
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn( override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default, scope = coroutineScope + Dispatchers.Default,
@ -72,49 +151,52 @@ class FilterCoordinator @Inject constructor(
initialValue = FilterHeaderModel( initialValue = FilterHeaderModel(
chips = emptyList(), chips = emptyList(),
sortOrder = repository.defaultSortOrder, sortOrder = repository.defaultSortOrder,
hasSelectedTags = false, isFilterApplied = false,
allowMultipleTags = repository.isMultipleTagsSupported,
), ),
) )
init {
observeState()
}
override fun applyFilter(tags: Set<MangaTag>) { override fun applyFilter(tags: Set<MangaTag>) {
setTags(tags) setTags(tags)
} }
override fun onSortItemClick(item: FilterItem.Sort) { override fun setSortOrder(value: SortOrder) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy(sortOrder = item.order) oldValue.copy(sortOrder = value)
} }
repository.defaultSortOrder = item.order repository.defaultSortOrder = value
} }
override fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) { override fun setLanguage(value: Locale?) {
currentState.update { oldValue -> currentState.update { oldValue ->
val newTags = if (!item.isMultiple) { oldValue.copy(locale = value)
if (isFromChip && item.isChecked) { }
emptySet() }
override fun setTag(value: MangaTag, addOrRemove: Boolean) {
currentState.update { oldValue ->
val newTags = if (repository.isMultipleTagsSupported) {
if (addOrRemove) {
oldValue.tags + value
} else { } else {
setOf(item.tag) oldValue.tags - value
} }
} else if (item.isChecked) {
oldValue.tags - item.tag
} else { } else {
oldValue.tags + item.tag if (addOrRemove) {
setOf(value)
} else {
emptySet()
}
} }
oldValue.copy(tags = newTags) oldValue.copy(tags = newTags)
} }
} }
override fun onStateItemClick(item: FilterItem.State) { override fun setState(value: MangaState, addOrRemove: Boolean) {
currentState.update { oldValue -> currentState.update { oldValue ->
val newStates = if (item.isChecked) { val newStates = if (addOrRemove) {
oldValue.states - item.state oldValue.states + value
} else { } else {
oldValue.states + item.state oldValue.states - value
} }
oldValue.copy(states = newStates) oldValue.copy(states = newStates)
} }
@ -125,7 +207,7 @@ class FilterCoordinator @Inject constructor(
oldValue.copy( oldValue.copy(
sortOrder = oldValue.sortOrder, sortOrder = oldValue.sortOrder,
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
locale = null, locale = if (item.payload == R.string.language) null else oldValue.locale,
states = if (item.payload == R.string.state) emptySet() else oldValue.states, states = if (item.payload == R.string.state) emptySet() else oldValue.states,
) )
} }
@ -135,7 +217,7 @@ class FilterCoordinator @Inject constructor(
if (!availableTagsDeferred.isCompleted) { if (!availableTagsDeferred.isCompleted) {
emit(emptySet()) emit(emptySet())
} }
emit(availableTagsDeferred.await()) emit(availableTagsDeferred.await().getOrNull())
} }
fun observeState() = currentState.asStateFlow() fun observeState() = currentState.asStateFlow()
@ -154,10 +236,6 @@ class FilterCoordinator @Inject constructor(
fun snapshot() = currentState.value fun snapshot() = currentState.value
fun performSearch(query: String) {
searchQuery.value = query
}
private fun getHeaderFlow() = combine( private fun getHeaderFlow() = combine(
observeState(), observeState(),
observeAvailableTags(), observeAvailableTags(),
@ -166,28 +244,46 @@ class FilterCoordinator @Inject constructor(
FilterHeaderModel( FilterHeaderModel(
chips = chips, chips = chips,
sortOrder = state.sortOrder, sortOrder = state.sortOrder,
hasSelectedTags = state.tags.isNotEmpty(), isFilterApplied = !state.isEmpty(),
allowMultipleTags = repository.isMultipleTagsSupported,
) )
} }
private fun getItemsFlow() = combine(
getTagsAsFlow(),
currentState,
searchQuery,
) { tags, state, query ->
buildFilterList(tags, state, query)
}
private fun getTagsAsFlow() = flow { private fun getTagsAsFlow() = flow {
val localTags = localTags.get() val localTags = localTags.get()
emit(TagsWrapper(localTags, isLoading = true, isError = false)) emit(PendingData(localTags, isLoading = true, error = null))
val remoteTags = tryLoadTags() tryLoadTags()
if (remoteTags == null) { .onSuccess { remoteTags ->
emit(TagsWrapper(localTags, isLoading = false, isError = true)) emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
}.onFailure {
emit(PendingData(localTags, isLoading = false, error = it))
}
}
private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow {
emit(PendingData(emptySet(), isLoading = true, error = null))
tryLoadLocales()
.onSuccess { locales ->
emit(PendingData(locales, isLoading = false, error = null))
}.onFailure {
emit(PendingData(emptySet(), isLoading = false, error = it))
}
}
private fun getTopTagsAsFlow(selectedTags: Flow<Set<MangaTag>>, limit: Int): Flow<PendingData<MangaTag>> = combine(
selectedTags.map {
if (it.isEmpty()) {
searchRepository.getTagsSuggestion("", limit, repository.source)
} else { } else {
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) searchRepository.getTagsSuggestion(it).take(limit)
}
},
getTagsAsFlow(),
) { suggested, all ->
val res = suggested.toMutableList()
if (res.size < limit) {
res.addAll(all.items.shuffled().take(limit - res.size))
} }
PendingData(res, all.isLoading, all.error.takeIf { res.size < limit })
} }
private suspend fun createChipsList( private suspend fun createChipsList(
@ -237,84 +333,40 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
@WorkerThread private suspend fun tryLoadTags(): Result<Set<MangaTag>> {
private fun buildFilterList(
allTags: TagsWrapper,
state: MangaListFilter.Advanced,
query: String,
): List<ListModel> {
val sortOrders = repository.sortOrders.sortedByOrdinal()
val states = repository.states
val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
val isMultiTag = repository.isMultipleTagsSupported
if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) {
list.add(ListHeader(R.string.sort_order))
sortOrders.mapTo(list) {
FilterItem.Sort(it, isSelected = it == state.sortOrder)
}
}
if (states.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.state,
buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset,
payload = R.string.state,
),
)
states.mapTo(list) {
FilterItem.State(it, isChecked = it in state.states)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.genres,
buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset,
payload = R.string.genres,
),
)
tags.mapTo(list) {
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
}
}
if (allTags.isError) {
list.add(FilterItem.Error(R.string.filter_load_error))
} else if (allTags.isLoading) {
list.add(LoadingFooter())
}
} else {
tags.mapNotNullTo(list) {
if (it.title.contains(query, ignoreCase = true)) {
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
} else {
null
}
}
if (list.isEmpty()) {
list.add(FilterItem.Error(R.string.nothing_found))
}
}
return list
}
private suspend fun tryLoadTags(): Set<MangaTag>? {
val shouldRetryOnError = availableTagsDeferred.isCompleted val shouldRetryOnError = availableTagsDeferred.isCompleted
val result = availableTagsDeferred.await() val result = availableTagsDeferred.await()
if (result == null && shouldRetryOnError) { if (result.isFailure && shouldRetryOnError) {
availableTagsDeferred = loadTagsAsync() availableTagsDeferred = loadTagsAsync()
return availableTagsDeferred.await() return availableTagsDeferred.await()
} }
return result return result
} }
private suspend fun tryLoadLocales(): Result<Set<Locale>> {
val shouldRetryOnError = availableLocalesDeferred.isCompleted
val result = availableLocalesDeferred.await()
if (result.isFailure && shouldRetryOnError) {
availableLocalesDeferred = loadLocalesAsync()
return availableLocalesDeferred.await()
}
return result
}
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable { runCatchingCancellable {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
}.getOrNull() }
}
private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable {
repository.getLocales()
}.onFailure { error ->
error.printStackTraceDebug()
}
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
@ -324,12 +376,41 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
private data class TagsWrapper( private fun loadAllTags() {
val tags: Set<MangaTag>, val prevJob = allTagsLoadJob
allTagsLoadJob = coroutineScope.launch(Dispatchers.Default) {
runCatchingCancellable {
prevJob?.cancelAndJoin()
appendTagsList(localTags.get(), isLoading = true)
appendTagsList(availableTagsDeferred.await().getOrThrow(), isLoading = false)
}.onFailure { e ->
allTags.value = allTags.value.filterIsInstance<TagCatalogItem>() + e.toErrorFooter()
}
}
}
private fun appendTagsList(newTags: Collection<MangaTag>, isLoading: Boolean) = allTags.update { oldList ->
val oldTags = oldList.filterIsInstance<TagCatalogItem>()
buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) {
addAll(oldTags)
newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) }
val tempSet = HashSet<MangaTag>(size)
removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) }
sortBy { (it as TagCatalogItem).tag.title }
if (isLoading) {
add(LoadingFooter())
}
}
}
private data class PendingData<T>(
val items: Collection<T>,
val isLoading: Boolean, val isLoading: Boolean,
val isError: Boolean, val error: Throwable?,
) )
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> { private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
private val collator = lc?.let { Collator.getInstance(Locale(it)) } private val collator = lc?.let { Collator.getInstance(Locale(it)) }

@ -13,7 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -37,10 +37,9 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag val tag = data as? MangaTag
if (tag == null) { if (tag == null) {
FilterSheetFragment.show(parentFragmentManager) TagsCatalogSheet.show(parentFragmentManager)
} else { } else {
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked) filter.setTag(tag, chip.isChecked)
filter.onTagItemClick(filterItem, isFromChip = true)
} }
} }

@ -1,59 +0,0 @@
package org.koitharu.kotatsu.filter.ui
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(),
AdaptiveSheetCallback,
AsyncListDiffer.ListListener<ListModel> {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val filter = (requireActivity() as FilterOwner).filter
addSheetCallback(this)
val adapter = FilterAdapter(filter, this)
binding.recyclerView.adapter = adapter
filter.filterItems.observe(viewLifecycleOwner, adapter)
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true))
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.recyclerView.scrollIndicators = 0
}
}
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
if (currentList.size > previousList.size && view != null) {
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
companion object {
private const val TAG = "FilterBottomSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
}
}

@ -2,12 +2,24 @@ package org.koitharu.kotatsu.filter.ui
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Locale
interface MangaFilter : OnFilterChangedListener { interface MangaFilter : OnFilterChangedListener {
val filterItems: StateFlow<List<ListModel>> val allTags: StateFlow<List<ListModel>>
val filterTags: StateFlow<FilterProperty<MangaTag>>
val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
val filterState: StateFlow<FilterProperty<MangaState>>
val filterLocale: StateFlow<FilterProperty<Locale?>>
val header: StateFlow<FilterHeaderModel> val header: StateFlow<FilterHeaderModel>

@ -1,13 +1,18 @@
package org.koitharu.kotatsu.filter.ui package org.koitharu.kotatsu.filter.ui
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.Locale
interface OnFilterChangedListener : ListHeaderClickListener { interface OnFilterChangedListener : ListHeaderClickListener {
fun onSortItemClick(item: FilterItem.Sort) fun setSortOrder(value: SortOrder)
fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) fun setLanguage(value: Locale?)
fun onStateItemClick(item: FilterItem.State) fun setTag(value: MangaTag, addOrRemove: Boolean)
fun setState(value: MangaState, addOrRemove: Boolean)
} }

@ -3,33 +3,12 @@ package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
class FilterHeaderModel( data class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>, val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?, val sortOrder: SortOrder?,
val hasSelectedTags: Boolean, val isFilterApplied: Boolean,
val allowMultipleTags: Boolean,
) { ) {
val textSummary: String val textSummary: String
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString() get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FilterHeaderModel
if (chips != other.chips) return false
if (allowMultipleTags != other.allowMultipleTags) return false
return sortOrder == other.sortOrder
// Not need to check hasSelectedTags
}
override fun hashCode(): Int {
var result = chips.hashCode()
result = 31 * result + allowMultipleTags.hashCode()
result = 31 * result + (sortOrder?.hashCode() ?: 0)
return result
}
} }

@ -1,75 +0,0 @@
package org.koitharu.kotatsu.filter.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
sealed interface FilterItem : ListModel {
data class Sort(
val order: SortOrder,
val isSelected: Boolean,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Sort && other.order == order
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Sort && previousState.isSelected != isSelected) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Tag(
val tag: MangaTag,
val isMultiple: Boolean,
val isChecked: Boolean,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Tag && other.isMultiple == isMultiple && other.tag == tag
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Tag && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class State(
val state: MangaState,
val isChecked: Boolean
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is State && other.state == state
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is State && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Error(
@StringRes val textResId: Int,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Error && textResId == other.textResId
}
}
}

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.filter.ui.model
data class FilterProperty<T>(
val availableItems: List<T>,
val selectedItems: Set<T>,
val isLoading: Boolean,
val error: Throwable?,
) {
fun isEmpty(): Boolean = availableItems.isEmpty()
}

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
data class TagCatalogItem(
val tag: MangaTag,
val isChecked: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is TagCatalogItem && other.tag == tag
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}

@ -0,0 +1,197 @@
package org.koitharu.kotatsu.filter.ui.sheet
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.view.isGone
import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager
import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
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.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
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
class FilterSheetFragment :
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null) {
binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.scrollView.scrollIndicators = 0
}
}
val filter = requireFilter()
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
binding.spinnerLocale.onItemSelectedListener = this
binding.spinnerOrder.onItemSelectedListener = this
binding.chipsState.onChipClickListener = this
binding.chipsGenres.onChipClickListener = this
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = requireFilter()
when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position])
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onChipClick(chip: Chip, data: Any?) {
val filter = requireFilter()
when (data) {
is MangaState -> filter.setState(data, chip.isChecked)
is MangaTag -> filter.setTag(data, chip.isChecked)
null -> TagsCatalogSheet.show(childFragmentManager)
}
}
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
val b = viewBinding ?: return
b.textViewOrderTitle.isGone = value.isEmpty()
b.cardOrder.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.single()
b.spinnerOrder.adapter = ArrayAdapter(
b.spinnerOrder.context,
android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1,
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerOrder.setSelection(selectedIndex, false)
}
}
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
val b = viewBinding ?: return
b.textViewLocaleTitle.isGone = value.isEmpty()
b.cardLocale.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
val selected = value.selectedItems.singleOrNull()
b.spinnerLocale.adapter = ArrayAdapter(
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)
},
)
val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) {
b.spinnerLocale.setSelection(selectedIndex, false)
}
}
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
val b = viewBinding ?: return
b.textViewGenresTitle.isGone = value.isEmpty()
b.chipsGenres.isGone = value.isEmpty()
b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources)
if (value.isEmpty()) {
return
}
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,
)
}
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,
)
} else {
null
}
}
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)
}
private fun onStateChanged(value: FilterProperty<MangaState>) {
val b = viewBinding ?: return
b.textViewStateTitle.isGone = value.isEmpty()
b.chipsState.isGone = value.isEmpty()
if (value.isEmpty()) {
return
}
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,
)
}
b.chipsState.setChips(chips)
}
private fun requireFilter() = (requireActivity() as FilterOwner).filter
companion object {
private const val TAG = "FilterSheet"
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
}
}

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.filter.ui.tags
import android.content.Context
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class TagsCatalogAdapter(
listener: OnListItemClickListener<TagCatalogItem>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase()
}
private fun tagCatalogDelegate(
listener: OnListItemClickListener<TagCatalogItem>,
) = adapterDelegateViewBinding<TagCatalogItem, ListModel, ItemCheckableNewBinding>(
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onItemClick(item, itemView)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
}
}
}

@ -0,0 +1,96 @@
package org.koitharu.kotatsu.filter.ui.tags
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetTagsBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@AndroidEntryPoint
class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickListener<TagCatalogItem>, TextWatcher,
AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener {
private val viewModel by viewModels<TagsCatalogViewModel>(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create((requireActivity() as FilterOwner).filter)
}
},
)
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding {
return SheetTagsBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = TagsCatalogAdapter(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
binding.editSearch.setText(viewModel.searchQuery.value)
binding.editSearch.addTextChangedListener(this)
binding.editSearch.onFocusChangeListener = this
binding.editSearch.setOnEditorActionListener(this)
viewModel.content.observe(viewLifecycleOwner, adapter)
addSheetCallback(this)
disableFitToContents()
}
override fun onItemClick(item: TagCatalogItem, view: View) {
val filter = (requireActivity() as FilterOwner).filter
filter.setTag(item.tag, !item.isChecked)
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
setExpanded(
isExpanded = hasFocus || isExpanded,
isLocked = hasFocus,
)
}
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_SEARCH) {
v.clearFocus()
true
} else {
false
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val q = s?.toString().orEmpty()
viewModel.searchQuery.value = q
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
companion object {
private const val TAG = "TagsCatalogSheet"
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG)
}
}

@ -0,0 +1,60 @@
package org.koitharu.kotatsu.filter.ui.tags
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
import org.koitharu.kotatsu.list.ui.model.LoadingState
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
class TagsCatalogViewModel @AssistedInject constructor(
@Assisted filter: MangaFilter,
mangaRepositoryFactory: MangaRepository.Factory,
dataRepository: MangaDataRepository,
) : BaseViewModel() {
val searchQuery = MutableStateFlow("")
private val tags = combine(
filter.allTags,
filter.filterTags.map { it.selectedItems },
) { all, selected ->
all.map { x ->
if (x is TagCatalogItem) {
val checked = x.tag in selected
if (x.isChecked == checked) {
x
} else {
x.copy(isChecked = checked)
}
} else {
x
}
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value)
val content = combine(tags, searchQuery) { raw, query ->
raw.filter { x ->
x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
@AssistedFactory
interface Factory {
fun create(filter: MangaFilter): TagsCatalogViewModel
}
}

@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTag

@ -45,7 +45,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HistoryListViewModel @Inject constructor( class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val settings: AppSettings, settings: AppSettings,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
networkState: NetworkState, networkState: NetworkState,

@ -7,14 +7,16 @@ import org.koitharu.kotatsu.list.ui.model.ErrorFooter
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
fun errorFooterAD( fun errorFooterAD(
listener: MangaListListener, listener: MangaListListener?,
) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>( ) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>(
{ inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) },
) { ) {
if (listener != null) {
binding.root.setOnClickListener { binding.root.setOnClickListener {
listener.onRetryClick(item.exception) listener.onRetryClick(item.exception)
} }
}
bind { bind {
binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources) binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources)

@ -6,6 +6,7 @@ enum class ListItemType {
FILTER_TAG, FILTER_TAG,
FILTER_TAG_MULTI, FILTER_TAG_MULTI,
FILTER_STATE, FILTER_STATE,
FILTER_LANGUAGE,
HEADER, HEADER,
MANGA_LIST, MANGA_LIST,
MANGA_LIST_DETAILED, MANGA_LIST_DETAILED,

@ -31,6 +31,7 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_TAG, ListItemType.FILTER_TAG,
ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE, ListItemType.FILTER_STATE,
ListItemType.FILTER_LANGUAGE,
-> outRect.set(0) -> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,

@ -28,10 +28,10 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
@ -57,8 +57,10 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
binding.textViewAuthor.setOnClickListener(this) binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.buttonOpen.setOnClickListener(this) binding.buttonOpen.setOnClickListener(this)
binding.buttonRead.setOnClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated)
viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
} }
@ -71,6 +73,14 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
DetailsActivity.newIntent(v.context, manga), DetailsActivity.newIntent(v.context, manga),
) )
R.id.button_read -> {
startActivity(
ReaderActivity.IntentBuilder(v.context)
.manga(manga)
.build(),
)
}
R.id.textView_author -> startActivity( R.id.textView_author -> startActivity(
SearchActivity.newIntent( SearchActivity.newIntent(
context = v.context, context = v.context,
@ -98,8 +108,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
if (filter == null) { if (filter == null) {
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
} else { } else {
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false) filter.setTag(tag, true)
filter.onTagItemClick(filterItem, isFromChip = false)
closeSelf() closeSelf()
} }
} }
@ -120,6 +129,43 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
} }
} }
private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) {
with(requireViewBinding()) {
toolbarBottom.isVisible = footer != null
if (footer == null) {
return
}
toolbarBottom.title = when {
footer.isInProgress() -> {
getString(R.string.chapter_d_of_d, footer.currentChapter, footer.totalChapters)
}
footer.totalChapters > 0 -> {
resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters)
}
else -> {
getString(R.string.no_chapters)
}
}
buttonRead.isEnabled = footer.totalChapters > 0
buttonRead.setIconResource(
when {
footer.isIncognito -> R.drawable.ic_incognito
footer.isInProgress() -> R.drawable.ic_play
else -> R.drawable.ic_read
},
)
buttonRead.setText(
if (footer.isInProgress()) {
R.string._continue
} else {
R.string.read
},
)
}
}
private fun onDescriptionChanged(description: CharSequence?) { private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding?.textViewDescription ?: return val tv = viewBinding?.textViewDescription ?: return
when { when {

@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@ -25,6 +28,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import javax.inject.Inject import javax.inject.Inject
@ -33,6 +37,7 @@ class PreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
) : BaseViewModel() { ) : BaseViewModel() {
@ -40,6 +45,26 @@ class PreviewViewModel @Inject constructor(
savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga, savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
) )
val footer = combine(
manga,
historyRepository.observeOne(manga.value.id),
manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(),
) { m, history, incognito ->
if (m.chapters == null) {
return@combine null
}
val b = m.getPreferredBranch(history)
val chapters = m.getChapters(b).orEmpty()
FooterInfo(
branch = b,
currentChapter = history?.chapterId?.let {
chapters.indexOfFirst { x -> x.id == it }
} ?: -1,
totalChapters = chapters.size,
isIncognito = incognito,
)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val description = manga val description = manga
.distinctUntilChangedBy { it.description.orEmpty() } .distinctUntilChangedBy { it.description.orEmpty() }
.transformLatest { .transformLatest {
@ -82,4 +107,14 @@ class PreviewViewModel @Inject constructor(
} }
return spannable.trim() return spannable.trim()
} }
data class FooterInfo(
val branch: String?,
val currentChapter: Int,
val totalChapters: Int,
val isIncognito: Boolean,
) {
fun isInProgress() = currentChapter >= 0
}
} }

@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -132,7 +133,7 @@ class LocalMangaRepository @Inject constructor(
}.getOrNull() }.getOrNull()
} }
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// fast path // fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga() return it.getManga()
@ -154,12 +155,16 @@ class LocalMangaRepository @Inject constructor(
} }
} }
}.firstOrNull()?.getManga() }.firstOrNull()?.getManga()
} }.onFailure {
it.printStackTraceDebug()
}.getOrNull()
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
override suspend fun getLocales() = emptySet<Locale>()
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList() override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? { suspend fun getOutputDir(manga: Manga): File? {

@ -20,6 +20,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.TreeMap
import java.util.zip.ZipFile import java.util.zip.ZipFile
/** /**
@ -49,8 +50,15 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.mapIndexed { i, c -> chapters = info.chapters?.mapIndexedNotNull { i, c ->
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL) val fileName = index.getChapterFileName(c.id)
val file = if (fileName != null) {
chapterFiles[fileName]
} else {
// old downloads
chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
@ -59,7 +67,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
id = "$i${f.name}".longHashCode(), id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
@ -120,9 +128,9 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase() private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.walkCompat() private fun getChaptersFiles() = root.walkCompat()
.filter { it.hasCbzExtension() } .filter { it.hasCbzExtension() }
.toListSorted(compareBy(AlphanumComparator()) { it.name }) .associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? { private fun findFirstImageEntry(): String? {
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()

@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File import java.io.File
@ -54,7 +55,8 @@ sealed class LocalMangaInput(
zip.isFile -> LocalMangaZipInput(zip) zip.isFile -> LocalMangaZipInput(zip)
else -> null else -> null
} }
if (input?.getMangaInfo()?.id == manga.id) { val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
if (info?.id == manga.id) {
send(input) send(input)
} }
} }

@ -5,9 +5,11 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File import java.io.File
@ -86,7 +88,11 @@ sealed class LocalMangaOutput(
} }
private suspend fun canWriteTo(file: File, manga: Manga): Boolean { private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
val info = LocalMangaInput.of(file).getMangaInfo() ?: return false val info = runCatchingCancellable {
LocalMangaInput.of(file).getMangaInfo()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull() ?: return false
return info.id == manga.id return info.id == manga.id
} }
} }

@ -18,8 +18,8 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterOwner import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
@ -94,7 +94,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
Snackbar.make( Snackbar.make(
requireViewBinding().recyclerView, requireViewBinding().recyclerView,
R.string.removal_completed, R.string.removal_completed,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }
@ -103,7 +103,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
fun newInstance() = LocalListFragment().withArgs(1) { fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable( putSerializable(
RemoteListFragment.ARG_SOURCE, RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL MangaSource.LOCAL,
) // required by FilterCoordinator ) // required by FilterCoordinator
} }
} }

@ -12,6 +12,7 @@ import androidx.work.WorkerParameters
import androidx.work.await import androidx.work.await
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -20,10 +21,12 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
@Assisted appContext: Context, @Assisted appContext: Context,
@Assisted params: WorkerParameters, @Assisted params: WorkerParameters,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val dataRepository: MangaDataRepository,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return if (localMangaRepository.cleanup()) { return if (localMangaRepository.cleanup()) {
dataRepository.cleanupLocalManga()
Result.success() Result.success()
} else { } else {
Result.retry() Result.retry()

@ -13,8 +13,6 @@ import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -47,7 +45,6 @@ import org.koitharu.kotatsu.core.util.ext.hideKeyboard
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@ -55,6 +52,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@ -66,7 +64,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -98,17 +95,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater)) setContentView(ActivityMainBinding.inflate(layoutInflater))
if (bottomNav != null) {
ViewCompat.setOnApplyWindowInsetsListener(viewBinding.root) { _, insets ->
if (insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0) {
val elevation = bottomNav?.elevation ?: 0f
window.setNavigationBarTransparentCompat(this@MainActivity, elevation)
}
insets
}
ViewCompat.requestApplyInsets(viewBinding.root)
}
with(viewBinding.searchView) { with(viewBinding.searchView) {
onFocusChangeListener = this@MainActivity onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity searchSuggestionListener = this@MainActivity
@ -142,7 +128,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this)) viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) { viewModel.onFirstStart.observeEvent(this) {
OnboardDialogFragment.show(supportFragmentManager) WelcomeSheet.show(supportFragmentManager)
} }
viewModel.isIncognitoMode.observe(this) { viewModel.isIncognitoMode.observe(this) {
adjustSearchUI(isSearchOpened(), false) adjustSearchUI(isSearchOpened(), false)

@ -0,0 +1,133 @@
package org.koitharu.kotatsu.main.ui.welcome
import android.accounts.AccountManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isGone
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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.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
@AndroidEntryPoint
class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipClickListener, View.OnClickListener,
ActivityResultCallback<Uri?> {
private val viewModel by viewModels<WelcomeViewModel>()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this,
)
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding {
return SheetWelcomeBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet)
binding.chipsLocales.onChipClickListener = this
binding.chipsType.onChipClickListener = this
binding.chipBackup.setOnClickListener(this)
binding.chipSync.setOnClickListener(this)
viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged)
viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged)
}
override fun onChipClick(chip: Chip, data: Any?) {
when (data) {
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.chip_backup -> {
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
Snackbar.make(
v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
}
R.id.chip_sync -> {
val am = AccountManager.get(v.context)
val accountType = getString(R.string.account_type_sync)
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
}
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
RestoreDialogFragment.show(parentFragmentManager, result)
}
}
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,
isCheckable = true,
isChecked = it in value.selectedItems,
data = it,
)
},
)
}
private fun onTypesChanged(value: FilterProperty<ContentType>) {
val chips = viewBinding?.chipsType ?: return
chips.setChips(
value.availableItems.map {
ChipsView.ChipModel(
tint = 0,
title = getString(it.titleResId),
icon = 0,
isCheckable = true,
isChecked = it in value.selectedItems,
data = it,
)
},
)
}
companion object {
private const val TAG = "WelcomeSheet"
fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG)
fun dismiss(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false
sheet.dismissAllowingStateLoss()
return true
}
}
}

@ -0,0 +1,107 @@
package org.koitharu.kotatsu.main.ui.welcome
import android.content.Context
import androidx.core.os.ConfigurationCompat
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
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.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class WelcomeViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
@ApplicationContext context: Context,
) : BaseViewModel() {
private val allSources = repository.allMangaSources
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
private var updateJob: Job
val locales = MutableStateFlow(
FilterProperty<Locale?>(
availableItems = listOf(null),
selectedItems = setOf(null),
isLoading = true,
error = null,
),
)
val types = MutableStateFlow(
FilterProperty(
availableItems = ContentType.entries.toList(),
selectedItems = setOf(ContentType.MANGA),
isLoading = false,
error = null,
),
)
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()
.firstNotNullOfOrNull { lc -> languages[lc.language] }
selectedLocales += null
locales.value = locales.value.copy(
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
selectedItems = selectedLocales,
isLoading = false,
)
}
}
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
val snapshot = locales.value
locales.value = snapshot.copy(
selectedItems = if (isChecked) {
snapshot.selectedItems + locale
} else {
snapshot.selectedItems - locale
},
)
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob.join()
commit()
}
}
fun setTypeChecked(type: ContentType, isChecked: Boolean) {
val snapshot = types.value
types.value = snapshot.copy(
selectedItems = if (isChecked) {
snapshot.selectedItems + type
} else {
snapshot.selectedItems - type
},
)
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob.join()
commit()
}
}
private suspend fun commit() {
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
}
repository.setSourcesEnabledExclusive(enabledSources)
}
}

@ -7,13 +7,17 @@ data class ReaderColorFilter(
val brightness: Float, val brightness: Float,
val contrast: Float, val contrast: Float,
val isInverted: Boolean, val isInverted: Boolean,
val isGrayscale: Boolean,
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = !isInverted && brightness == 0f && contrast == 0f get() = !isGrayscale && !isInverted && brightness == 0f && contrast == 0f
fun toColorFilter(): ColorMatrixColorFilter { fun toColorFilter(): ColorMatrixColorFilter {
val cm = ColorMatrix() val cm = ColorMatrix()
if (isGrayscale) {
cm.grayscale()
}
if (isInverted) { if (isInverted) {
cm.inverted() cm.inverted()
} }
@ -49,6 +53,20 @@ data class ReaderColorFilter(
0.0f, 0.0f, -1.0f, 1.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
) )
set(matrix) postConcat(ColorMatrix(matrix))
}
private fun ColorMatrix.grayscale() {
setSaturation(0f)
}
companion object {
val EMPTY = ReaderColorFilter(
brightness = 0.0f,
contrast = 0.0f,
isInverted = false,
isGrayscale = false,
)
} }
} }

@ -16,6 +16,7 @@ import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import coil.size.ViewSizeResolver import coil.size.ViewSizeResolver
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -62,6 +63,7 @@ class ColorFilterConfigActivity :
viewBinding.sliderContrast.setLabelFormatter(formatter) viewBinding.sliderContrast.setLabelFormatter(formatter)
viewBinding.sliderBrightness.setLabelFormatter(formatter) viewBinding.sliderBrightness.setLabelFormatter(formatter)
viewBinding.switchInvert.setOnCheckedChangeListener(this) viewBinding.switchInvert.setOnCheckedChangeListener(this)
viewBinding.switchGrayscale.setOnCheckedChangeListener(this)
viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonDone.setOnClickListener(this)
viewBinding.buttonReset.setOnClickListener(this) viewBinding.buttonReset.setOnClickListener(this)
@ -84,18 +86,16 @@ class ColorFilterConfigActivity :
} }
} }
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
viewModel.setInversion(isChecked) when (buttonView.id) {
R.id.switch_invert -> viewModel.setInversion(isChecked)
R.id.switch_grayscale -> viewModel.setGrayscale(isChecked)
}
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_done -> if (viewBinding.checkBoxGlobal.isChecked) { R.id.button_done -> showSaveConfirmation()
viewModel.saveGlobally()
} else {
viewModel.save()
}
R.id.button_reset -> viewModel.reset() R.id.button_reset -> viewModel.reset()
} }
} }
@ -113,10 +113,23 @@ class ColorFilterConfigActivity :
} }
} }
fun showSaveConfirmation() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.apply)
.setMessage(R.string.color_correction_apply_text)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.this_manga) { _, _ ->
viewModel.save()
}.setNeutralButton(R.string.globally) { _, _ ->
viewModel.saveGlobally()
}.show()
}
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false)
viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false)
viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
} }
@ -138,6 +151,8 @@ class ColorFilterConfigActivity :
private fun onLoadingChanged(isLoading: Boolean) { private fun onLoadingChanged(isLoading: Boolean) {
viewBinding.sliderContrast.isEnabled = !isLoading viewBinding.sliderContrast.isEnabled = !isLoading
viewBinding.sliderBrightness.isEnabled = !isLoading viewBinding.sliderBrightness.isEnabled = !isLoading
viewBinding.switchInvert.isEnabled = !isLoading
viewBinding.switchGrayscale.isEnabled = !isLoading
viewBinding.buttonDone.isEnabled = !isLoading viewBinding.buttonDone.isEnabled = !isLoading
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.reader.ui.colorfilter package org.koitharu.kotatsu.reader.ui.colorfilter
import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -8,7 +7,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
class ColorFilterConfigBackPressedDispatcher( class ColorFilterConfigBackPressedDispatcher(
private val context: Context, private val activity: ColorFilterConfigActivity,
private val viewModel: ColorFilterConfigViewModel, private val viewModel: ColorFilterConfigViewModel,
) : OnBackPressedCallback(true), DialogInterface.OnClickListener { ) : OnBackPressedCallback(true), DialogInterface.OnClickListener {
@ -24,12 +23,12 @@ class ColorFilterConfigBackPressedDispatcher(
when (which) { when (which) {
DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit) DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit)
DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss() DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss()
DialogInterface.BUTTON_POSITIVE -> viewModel.save() DialogInterface.BUTTON_POSITIVE -> activity.showSaveConfirmation()
} }
} }
private fun showConfirmation() { private fun showConfirmation() {
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(activity)
.setTitle(R.string.color_correction) .setTitle(R.string.color_correction)
.setMessage(R.string.text_unsaved_changes_prompt) .setMessage(R.string.text_unsaved_changes_prompt)
.setNegativeButton(R.string.discard, this) .setNegativeButton(R.string.discard, this)

@ -44,33 +44,19 @@ class ColorFilterConfigViewModel @Inject constructor(
} }
fun setBrightness(brightness: Float) { fun setBrightness(brightness: Float) {
val cf = colorFilter.value updateColorFilter { it.copy(brightness = brightness) }
colorFilter.value = ReaderColorFilter(
brightness = brightness,
contrast = cf?.contrast ?: 0f,
isInverted = cf?.isInverted ?: false,
).takeUnless { it.isEmpty }
} }
fun setContrast(contrast: Float) { fun setContrast(contrast: Float) {
val cf = colorFilter.value updateColorFilter { it.copy(contrast = contrast) }
colorFilter.value = ReaderColorFilter(
brightness = cf?.brightness ?: 0f,
contrast = contrast,
isInverted = cf?.isInverted ?: false,
).takeUnless { it.isEmpty }
} }
fun setInversion(invert: Boolean) { fun setInversion(invert: Boolean) {
val cf = colorFilter.value updateColorFilter { it.copy(isInverted = invert) }
if (invert == cf?.isInverted) {
return
} }
colorFilter.value = ReaderColorFilter(
brightness = cf?.brightness ?: 0f, fun setGrayscale(grayscale: Boolean) {
contrast = cf?.contrast ?: 0f, updateColorFilter { it.copy(isGrayscale = grayscale) }
isInverted = invert,
).takeUnless { it.isEmpty }
} }
fun reset() { fun reset() {
@ -85,7 +71,18 @@ class ColorFilterConfigViewModel @Inject constructor(
} }
fun saveGlobally() { fun saveGlobally() {
launchLoadingJob(Dispatchers.Default) {
settings.readerColorFilter = colorFilter.value settings.readerColorFilter = colorFilter.value
if (mangaDataRepository.getColorFilter(manga.id) != null) {
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
}
onDismiss.call(Unit) onDismiss.call(Unit)
} }
}
private inline fun updateColorFilter(block: (ReaderColorFilter) -> ReaderColorFilter) {
colorFilter.value = block(
colorFilter.value ?: ReaderColorFilter.EMPTY,
).takeUnless { it.isEmpty }
}
} }

@ -102,10 +102,10 @@ class ReaderSettings(
AppSettings.KEY_READER_BACKGROUND, AppSettings.KEY_READER_BACKGROUND,
AppSettings.KEY_32BIT_COLOR, AppSettings.KEY_32BIT_COLOR,
AppSettings.KEY_READER_OPTIMIZE, AppSettings.KEY_READER_OPTIMIZE,
AppSettings.KEY_CF_ENABLED,
AppSettings.KEY_CF_CONTRAST, AppSettings.KEY_CF_CONTRAST,
AppSettings.KEY_CF_BRIGHTNESS, AppSettings.KEY_CF_BRIGHTNESS,
AppSettings.KEY_CF_INVERTED, AppSettings.KEY_CF_INVERTED,
AppSettings.KEY_CF_GRAYSCALE,
) )
override suspend fun emit(value: ReaderColorFilter?) { override suspend fun emit(value: ReaderColorFilter?) {

@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
@ -69,9 +70,11 @@ abstract class BasePageHolder<B : ViewBinding>(
delegate.onRecycle() delegate.onRecycle()
} }
protected fun getBackgroundDownsampling() = when { protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) {
!settings.isReaderOptimizationEnabled -> 1 downsampling = when {
isForeground || !settings.isReaderOptimizationEnabled -> 1
context.isLowRamDevice() -> 8 context.isLowRamDevice() -> 8
else -> 4 else -> 4
} }
}
} }

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
@ -158,7 +159,9 @@ class PageHolderDelegate(
callback.onLoadingStarted() callback.onLoadingStarted()
yield() yield()
try { try {
val task = loader.loadPageAsync(data, force) val task = withContext(Dispatchers.Default) {
loader.loadPageAsync(data, force)
}
uri = coroutineScope { uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow()) val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await() val file = task.await()

@ -47,12 +47,12 @@ open class PageHolder(
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding.ssiv.downsampling = 1 binding.ssiv.applyDownsampling(isForeground = true)
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
binding.ssiv.downsampling = getBackgroundDownsampling() binding.ssiv.applyDownsampling(isForeground = false)
} }
override fun onConfigChanged() { override fun onConfigChanged() {
@ -60,7 +60,7 @@ open class PageHolder(
if (settings.applyBitmapConfig(binding.ssiv)) { if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload() delegate.reload()
} }
binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling() binding.ssiv.applyDownsampling(isResumed())
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")

@ -1,29 +0,0 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
class PagerPaginationListener(
private val adapter: RecyclerView.Adapter<*>,
private val offset: Int,
private val listener: OnBoundsScrollListener
) : ViewPager2.OnPageChangeCallback() {
private var firstItemId: Long = 0
private var lastItemId: Long = 0
override fun onPageSelected(position: Int) {
val itemCount = adapter.itemCount
if (itemCount == 0) {
return
}
if (position <= offset && adapter.getItemId(0) != firstItemId) {
firstItemId = adapter.getItemId(0)
listener.onScrolledToStart()
} else if (position >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
lastItemId = adapter.getItemId(itemCount - 1)
listener.onScrolledToEnd()
}
}
}

@ -1,32 +0,0 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
class ListPaginationListener(
private val offset: Int,
private val listener: OnBoundsScrollListener
) : RecyclerView.OnScrollListener() {
private var firstItemId: Long = 0
private var lastItemId: Long = 0
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val adapter = recyclerView.adapter ?: return
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val itemCount = adapter.itemCount
if (itemCount == 0) {
return
}
if (lastVisiblePosition >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
lastItemId = adapter.getItemId(itemCount - 1)
listener.onScrolledToEnd()
} else if (firstVisiblePosition <= offset && adapter.getItemId(0) != firstItemId) {
firstItemId = adapter.getItemId(0)
listener.onScrolledToStart()
}
}
}

@ -12,8 +12,10 @@ class WebtoonFrameLayout @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
val target: WebtoonImageView by lazy(LazyThreadSafetyMode.NONE) { private var _target: WebtoonImageView? = null
findViewById(R.id.ssiv) val target: WebtoonImageView
get() = _target ?: findViewById<WebtoonImageView?>(R.id.ssiv).also {
_target = it
} }
fun dispatchVerticalScroll(dy: Int): Int { fun dispatchVerticalScroll(dy: Int): Int {

@ -5,7 +5,6 @@ import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
@ -38,22 +37,22 @@ class WebtoonHolder(
bindingInfo.buttonErrorDetails.setOnClickListener(this) bindingInfo.buttonErrorDetails.setOnClickListener(this)
} }
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
// FIXME binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding.ssiv.downsampling = 1 binding.ssiv.applyDownsampling(isForeground = true)
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// FIXME binding.ssiv.downsampling = getBackgroundDownsampling() binding.ssiv.applyDownsampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.applyDownsampling(isResumed())
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
@ -97,9 +96,6 @@ class WebtoonHolder(
override fun onImageShowing(settings: ReaderSettings) { override fun onImageShowing(settings: ReaderSettings) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) { with(binding.ssiv) {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo( scrollTo(
when { when {
scrollToRestore != 0 -> scrollToRestore scrollToRestore != 0 -> scrollToRestore

@ -1,14 +1,16 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import androidx.core.view.ancestors import androidx.core.view.ancestors
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.resolveDp
private const val SCROLL_UNKNOWN = -1 import kotlin.math.roundToInt
class WebtoonImageView @JvmOverloads constructor( class WebtoonImageView @JvmOverloads constructor(
context: Context, context: Context,
@ -18,7 +20,14 @@ class WebtoonImageView @JvmOverloads constructor(
private val ct = PointF() private val ct = PointF()
private var scrollPos = 0 private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var debugPaint: Paint? = null
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (BuildConfig.DEBUG) {
drawDebug(canvas)
}
}
fun scrollBy(delta: Int) { fun scrollBy(delta: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
@ -41,14 +50,14 @@ class WebtoonImageView @JvmOverloads constructor(
fun getScroll() = scrollPos fun getScroll() = scrollPos
fun getScrollRange(): Int { fun getScrollRange(): Int {
if (scrollRange == SCROLL_UNKNOWN) { if (!isReady) {
computeScrollRange() return 0
} }
return scrollRange.coerceAtLeast(0) val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt()
return (totalHeight - height).coerceAtLeast(0)
} }
override fun recycle() { override fun recycle() {
scrollRange = SCROLL_UNKNOWN
scrollPos = 0 scrollPos = 0
super.recycle() super.recycle()
} }
@ -88,33 +97,54 @@ class WebtoonImageView @JvmOverloads constructor(
setMeasuredDimension(width, height) setMeasuredDimension(width, height)
} }
override fun onDownsamplingChanged() {
super.onDownsamplingChanged()
adjustScale()
}
override fun onReady() {
super.onReady()
adjustScale()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return if (oldh != h && oldw != 0 && oldh != 0 && isReady) {
ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll()
computeScrollRange() } else {
val container = ancestors.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return return
val parentHeight = parentHeight()
if (scrollPos != 0 && container.bottom < parentHeight) {
scrollTo(scrollRange)
} }
} }
private fun scrollToInternal(pos: Int) { private fun scrollToInternal(pos: Int) {
minScale = width / sWidth.toFloat()
maxScale = minScale
scrollPos = pos scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
setScaleAndCenter(minScale, ct) setScaleAndCenter(minScale, ct)
} }
private fun computeScrollRange() { private fun adjustScale() {
if (!isReady) { minScale = width / sWidth.toFloat()
return maxScale = minScale
} minimumScaleType = SCALE_TYPE_CUSTOM
val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0)
} }
private fun parentHeight(): Int { private fun parentHeight(): Int {
return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
} }
private fun drawDebug(canvas: Canvas) {
val paint = debugPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.RED
strokeWidth = context.resources.resolveDp(2f)
textAlign = android.graphics.Paint.Align.LEFT
textSize = context.resources.resolveDp(14f)
debugPaint = this
}
paint.style = Paint.Style.STROKE
canvas.drawRect(1f, 1f, width.toFloat() - 1f, height.toFloat() - 1f, paint)
paint.style = Paint.Style.FILL
canvas.drawText("${getScroll()} / ${getScrollRange()}", 100f, 100f, paint)
}
} }

@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.view.ViewCompat.TYPE_TOUCH import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.core.view.iterator
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import java.util.LinkedList import java.util.LinkedList
@ -16,6 +17,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null
private val detachedViews = WeakHashMap<View, Unit>() private val detachedViews = WeakHashMap<View, Unit>()
private var isFixingScroll: Boolean = false
override fun onChildDetachedFromWindow(child: View) { override fun onChildDetachedFromWindow(child: View) {
super.onChildDetachedFromWindow(child) super.onChildDetachedFromWindow(child)
@ -54,6 +56,13 @@ class WebtoonRecyclerView @JvmOverloads constructor(
return consumedY != 0 || dy == 0 return consumedY != 0 || dy == 0
} }
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
if (state == SCROLL_STATE_IDLE) {
updateChildrenScroll()
}
}
private fun consumeVerticalScroll(dy: Int): Int { private fun consumeVerticalScroll(dy: Int): Int {
if (childCount == 0) { if (childCount == 0) {
return 0 return 0
@ -121,6 +130,38 @@ class WebtoonRecyclerView @JvmOverloads constructor(
} }
} }
fun updateChildrenScroll() {
if (isFixingScroll) {
return
}
isFixingScroll = true
for (child in this) {
val ssiv = (child as WebtoonFrameLayout).target
if (adjustScroll(child, ssiv)) {
break
}
}
isFixingScroll = false
}
private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when {
child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> {
val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll())
scrollBy(0, -distance)
ssiv.scrollBy(distance)
true
}
child.top > 0 && ssiv.getScroll() > 0 -> {
val distance = minOf(child.top, ssiv.getScroll())
scrollBy(0, distance)
ssiv.scrollBy(-distance)
true
}
else -> false
}
abstract class OnPageScrollListener { abstract class OnPageScrollListener {
private var lastPosition = NO_POSITION private var lastPosition = NO_POSITION

@ -1,41 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class TargetScrollObserver(
private val recyclerView: RecyclerView,
) : RecyclerView.AdapterDataObserver() {
private var isScrollToCurrentPending = true
private val layoutManager: LinearLayoutManager
get() = recyclerView.layoutManager as LinearLayoutManager
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (isScrollToCurrentPending) {
postScroll()
}
}
private fun postScroll() {
recyclerView.post {
scrollToTarget()
}
}
private fun scrollToTarget() {
val adapter = recyclerView.adapter ?: return
if (recyclerView.computeVerticalScrollRange() == 0) {
return
}
val snapshot = (adapter as? AsyncListDifferDelegationAdapter<*>)?.items ?: return
val target = snapshot.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (target in snapshot.indices) {
layoutManager.scrollToPositionWithOffset(target, 0)
isScrollToCurrentPending = false
}
}
}

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

Loading…
Cancel
Save