From 45b5e48676514c2a0ef2cf38d8443c43b951c7ec Mon Sep 17 00:00:00 2001 From: Mac135135 Date: Fri, 5 Jul 2024 21:23:15 +0300 Subject: [PATCH 01/71] Add functionality to expand manga title on click --- .../kotatsu/details/ui/DetailsActivity.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index c62e78451..302d60824 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -3,6 +3,11 @@ package org.koitharu.kotatsu.details.ui import android.content.Context import android.content.Intent import android.graphics.Color +import android.view.MotionEvent +import android.content.ClipData +import android.content.ClipboardManager +import android.view.GestureDetector +import android.view.ViewGroup import android.os.Bundle import android.text.style.DynamicDrawableSpan import android.text.style.ForegroundColorSpan @@ -123,7 +128,7 @@ class DetailsActivity : lateinit var tagHighlighter: ListExtraProvider private val viewModel: DetailsViewModel by viewModels() - + private lateinit var gestureDetector: GestureDetector private lateinit var menuProvider: DetailsMenuProvider override fun onCreate(savedInstanceState: Bundle?) { @@ -157,6 +162,32 @@ class DetailsActivity : viewBinding.containerBottomSheet?.let { sheet -> onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) } + gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + val tv = viewBinding.textViewTitle + TransitionManager.beginDelayedTransition(tv.parent as ViewGroup) + if (tv.maxLines == 5) { + tv.maxLines = 20 // Expand text + } else { + tv.maxLines = 5 // Collapse text + } + return true + } + + override fun onLongPress(e: MotionEvent) { + val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("copied text", viewBinding.textViewTitle.text) + clipboardManager.setPrimaryClip(clip) + Toast.makeText(this@DetailsActivity, "Text copied", Toast.LENGTH_SHORT).show() + } + }) + + viewBinding.textViewTitle.setOnTouchListener { _, motionEvent -> + gestureDetector.onTouchEvent(motionEvent) + true + } + + viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) From 85d397def03c9870d7def28ec2fb9360f01c0dff Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 11 Sep 2024 08:50:51 +0300 Subject: [PATCH 02/71] Update dependencies --- app/build.gradle | 8 ++++---- build.gradle | 4 ++-- gradle.properties | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fc714b23a..fcfd591d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,11 +48,11 @@ android { } compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_11.toString() freeCompilerArgs += [ '-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', @@ -88,7 +88,7 @@ dependencies { } coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' - implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2' implementation 'androidx.appcompat:appcompat:1.7.0' diff --git a/build.gradle b/build.gradle index 71cb1681c..fadb8b78b 100644 --- a/build.gradle +++ b/build.gradle @@ -5,9 +5,9 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.6.0' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.10' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.52' - classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.10-1.0.24' + classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.20-1.0.25' } } diff --git a/gradle.properties b/gradle.properties index 0c9e2bae4..c628dc13b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,4 @@ kotlin.code.style=official org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M" android.enableR8.fullMode=true android.nonFinalResIds=false +kapt.use.k2=true From c480992f634a4370e51369cf11cef2aac4bdfaf2 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 14 Sep 2024 12:12:55 +0300 Subject: [PATCH 03/71] Option to automatically download new chapters (close #425, close #602, close #955) --- .../kotatsu/core/prefs/AppSettings.kt | 4 ++ .../kotatsu/core/prefs/ColorScheme.kt | 2 + .../kotatsu/core/prefs/DownloadFormat.kt | 3 ++ .../koitharu/kotatsu/core/prefs/ListMode.kt | 5 ++- .../koitharu/kotatsu/core/prefs/NavItem.kt | 2 + .../kotatsu/core/prefs/NetworkPolicy.kt | 2 + .../core/prefs/ProgressIndicatorMode.kt | 3 ++ .../kotatsu/core/prefs/ReaderAnimation.kt | 3 ++ .../kotatsu/core/prefs/ReaderBackground.kt | 2 + .../koitharu/kotatsu/core/prefs/ReaderMode.kt | 3 ++ .../kotatsu/core/prefs/ScreenshotsPolicy.kt | 3 ++ .../core/prefs/SearchSuggestionType.kt | 2 + .../core/prefs/TrackerDownloadStrategy.kt | 9 ++++ .../ui/pager/ChaptersPagesViewModel.kt | 5 ++- .../ui/worker/DownloadNotificationFactory.kt | 44 +++++++++++++------ .../download/ui/worker/DownloadWorker.kt | 16 ++++--- .../tracker/TrackerSettingsFragment.kt | 8 ++++ .../kotatsu/tracker/work/TrackWorker.kt | 40 ++++++++++++++--- app/src/main/res/values/arrays.xml | 4 ++ app/src/main/res/values/strings.xml | 3 ++ app/src/main/res/xml/pref_tracker.xml | 7 +++ 21 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TrackerDownloadStrategy.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 1cff809e4..3d3b82081 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -160,6 +160,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isTrackerNsfwDisabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false) + val trackerDownloadStrategy: TrackerDownloadStrategy + get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED) + var notificationSound: Uri get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() ?: Settings.System.DEFAULT_NOTIFICATION_URI @@ -600,6 +603,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw" + const val KEY_TRACKER_DOWNLOAD = "tracker_download" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt index c2ffee581..e927a35ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt @@ -1,11 +1,13 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep import androidx.annotation.StringRes import androidx.annotation.StyleRes import com.google.android.material.color.DynamicColors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.find +@Keep enum class ColorScheme( @StyleRes val styleResId: Int, @StringRes val titleResId: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt index 72acf3640..65b3b9c52 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class DownloadFormat { AUTOMATIC, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt index dc8269cad..5c2b77fc5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt @@ -1,6 +1,9 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class ListMode { LIST, DETAILED_LIST, GRID; -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt index 97a4f016d..03cb70a93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt @@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs import androidx.annotation.DrawableRes import androidx.annotation.IdRes +import androidx.annotation.Keep import androidx.annotation.StringRes import org.koitharu.kotatsu.R +@Keep enum class NavItem( @IdRes val id: Int, @StringRes val title: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt index fc5556801..cb6d546db 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.core.prefs import android.net.ConnectivityManager +import androidx.annotation.Keep +@Keep enum class NetworkPolicy( private val key: Int, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ProgressIndicatorMode.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ProgressIndicatorMode.kt index 6bf1da864..ca0d67cdd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ProgressIndicatorMode.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ProgressIndicatorMode.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class ProgressIndicatorMode { NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT; diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt index e95559322..30f9e46c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class ReaderAnimation { // Do not rename this diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt index 5422b0322..0f4d7da7e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt @@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.view.ContextThemeWrapper +import androidx.annotation.Keep import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import com.google.android.material.R as materialR +@Keep enum class ReaderBackground { DEFAULT, LIGHT, DARK, WHITE, BLACK; diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt index 7d9f5fd2f..09904b383 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class ReaderMode(val id: Int) { STANDARD(1), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt index cb673e570..eb83f6c29 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep + +@Keep enum class ScreenshotsPolicy { // Do not rename this diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt index f25ef6f99..c9cb56a33 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SearchSuggestionType.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.core.prefs +import androidx.annotation.Keep import androidx.annotation.StringRes import org.koitharu.kotatsu.R +@Keep enum class SearchSuggestionType( @StringRes val titleResId: Int, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TrackerDownloadStrategy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TrackerDownloadStrategy.kt new file mode 100644 index 000000000..fbdddfc61 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TrackerDownloadStrategy.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.core.prefs + +import androidx.annotation.Keep + +@Keep +enum class TrackerDownloadStrategy { + + DISABLED, DOWNLOADED; +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt index 9ef984551..8dca45cf1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt @@ -166,8 +166,9 @@ abstract class ChaptersPagesViewModel( fun download(chaptersIds: Set?) { launchJob(Dispatchers.Default) { downloadScheduler.schedule( - requireManga(), - chaptersIds, + manga = requireManga(), + chaptersIds = chaptersIds, + isSilent = false, ) onDownloadStarted.call(Unit) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 6c0ebd85d..05cdbd931 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -37,7 +37,8 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity import java.util.UUID import com.google.android.material.R as materialR -private const val CHANNEL_ID = "download" +private const val CHANNEL_ID_DEFAULT = "download" +private const val CHANNEL_ID_SILENT = "download_bg" private const val GROUP_ID = "downloads" class DownloadNotificationFactory @AssistedInject constructor( @@ -45,10 +46,11 @@ class DownloadNotificationFactory @AssistedInject constructor( private val workManager: WorkManager, private val coil: ImageLoader, @Assisted private val uuid: UUID, + @Assisted val isSilent: Boolean, ) { private val covers = HashMap() - private val builder = NotificationCompat.Builder(context, CHANNEL_ID) + private val builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT) private val mutex = Mutex() private val coverWidth = context.resources.getDimensionPixelSize( @@ -106,14 +108,18 @@ class DownloadNotificationFactory @AssistedInject constructor( } init { - createChannel() + createChannels() builder.setOnlyAlertOnce(true) builder.setDefaults(0) - builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + builder.foregroundServiceBehavior = if (isSilent) { + NotificationCompat.FOREGROUND_SERVICE_DEFERRED + } else { + NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } builder.setSilent(true) builder.setGroup(GROUP_ID) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - builder.priority = NotificationCompat.PRIORITY_DEFAULT + builder.priority = if (isSilent) NotificationCompat.PRIORITY_MIN else NotificationCompat.PRIORITY_DEFAULT } suspend fun create(state: DownloadState?): Notification = mutex.withLock { @@ -283,20 +289,30 @@ class DownloadNotificationFactory @AssistedInject constructor( }.getOrNull() } - private fun createChannel() { + private fun createChannels() { val manager = NotificationManagerCompat.from(context) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(R.string.downloads)) - .setVibrationEnabled(false) - .setLightsEnabled(false) - .setSound(null, null) - .build() - manager.createNotificationChannel(channel) + manager.createNotificationChannel( + NotificationChannelCompat.Builder(CHANNEL_ID_DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(R.string.downloads)) + .setVibrationEnabled(false) + .setLightsEnabled(false) + .setSound(null, null) + .build(), + ) + manager.createNotificationChannel( + NotificationChannelCompat.Builder(CHANNEL_ID_SILENT, NotificationManagerCompat.IMPORTANCE_MIN) + .setName(context.getString(R.string.downloads_background)) + .setVibrationEnabled(false) + .setLightsEnabled(false) + .setSound(null, null) + .setShowBadge(false) + .build(), + ) } @AssistedFactory interface Factory { - fun create(uuid: UUID): DownloadNotificationFactory + fun create(uuid: UUID, isSilent: Boolean): DownloadNotificationFactory } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index f3fc3661a..c678d25d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -104,7 +104,10 @@ class DownloadWorker @AssistedInject constructor( notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { - private val notificationFactory = notificationFactoryFactory.create(params.id) + private val notificationFactory = notificationFactoryFactory.create( + uuid = params.id, + isSilent = params.inputData.getBoolean(IS_SILENT, false), + ) private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY) @@ -120,8 +123,7 @@ class DownloadWorker @AssistedInject constructor( setForeground(getForegroundInfo()) val mangaId = inputData.getLong(MANGA_ID, 0L) val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() - lastPublishedState = DownloadState(manga, isIndeterminate = true) - publishState(DownloadState(manga, isIndeterminate = true)) + publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it }) val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val downloadedIds = getDoneChapters(manga) return try { @@ -380,7 +382,9 @@ class DownloadWorker @AssistedInject constructor( } val notification = notificationFactory.create(state) if (state.isFinalState) { - notificationManager.notify(id.toString(), id.hashCode(), notification) + if (!notificationFactory.isSilent) { + notificationManager.notify(id.toString(), id.hashCode(), notification) + } } else if (notificationThrottler.throttle()) { notificationManager.notify(id.hashCode(), notification) } else { @@ -426,10 +430,11 @@ class DownloadWorker @AssistedInject constructor( private val settings: AppSettings, ) { - suspend fun schedule(manga: Manga, chaptersIds: Collection?) { + suspend fun schedule(manga: Manga, chaptersIds: Collection?, isSilent: Boolean) { dataRepository.storeManga(manga) val data = Data.Builder() .putLong(MANGA_ID, manga.id) + .putBoolean(IS_SILENT, isSilent) if (!chaptersIds.isNullOrEmpty()) { data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray()) } @@ -549,6 +554,7 @@ class DownloadWorker @AssistedInject constructor( const val SLOWDOWN_DELAY = 200L const val MANGA_ID = "manga_id" const val CHAPTERS_IDS = "chapters" + const val IS_SILENT = "silent" const val TAG = "download" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index 32e488026..2d6d771e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -11,13 +11,17 @@ import android.view.View import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.fragment.app.viewModels +import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat +import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider @@ -50,6 +54,10 @@ class TrackerSettingsFragment : } } } + findPreference(AppSettings.KEY_TRACKER_DOWNLOAD)?.run { + entryValues = TrackerDownloadStrategy.entries.names() + setDefaultValueCompat(TrackerDownloadStrategy.DISABLED.name) + } dozeHelper.updatePreference() updateCategoriesEnabled() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 54ff5e78d..ddb9a9b8d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -25,6 +25,7 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkerParameters import androidx.work.await +import dagger.Lazy import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -47,10 +48,14 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.logs.TrackerLogger import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.onEachIndexed import org.koitharu.kotatsu.core.util.ext.trySetForeground +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.settings.SettingsActivity @@ -76,6 +81,8 @@ class TrackWorker @AssistedInject constructor( private val checkNewChaptersUseCase: CheckNewChaptersUseCase, private val workManager: WorkManager, @TrackerLogger private val logger: FileLogger, + private val localRepositoryLazy: Lazy, + private val downloadSchedulerLazy: Lazy, ) : CoroutineWorker(context, workerParams) { private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } @@ -144,12 +151,16 @@ class TrackWorker @AssistedInject constructor( if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) { notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) } - if (it is MangaUpdates.Failure) { - val e = it.error - logger.log("checkUpdatesAsync", e) - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) + when (it) { + is MangaUpdates.Failure -> { + val e = it.error + logger.log("checkUpdatesAsync", e) + if (e is CloudFlareProtectedException) { + CaptchaNotifier(applicationContext).notify(e) + } } + + is MangaUpdates.Success -> processDownload(it) } }.mapNotNull { when (it) { @@ -237,6 +248,25 @@ class TrackWorker @AssistedInject constructor( } }.build() + private suspend fun processDownload(mangaUpdates: MangaUpdates.Success) { + if (!mangaUpdates.isValid || mangaUpdates.newChapters.isEmpty()) { + return + } + when (settings.trackerDownloadStrategy) { + TrackerDownloadStrategy.DISABLED -> Unit + TrackerDownloadStrategy.DOWNLOADED -> { + val localManga = localRepositoryLazy.get().findSavedManga(mangaUpdates.manga) + if (localManga != null) { + downloadSchedulerLazy.get().schedule( + manga = mangaUpdates.manga, + chaptersIds = mangaUpdates.newChapters.mapToSet { it.id }, + isSilent = true, + ) + } + } + } + } + @Reusable class Scheduler @Inject constructor( private val workManager: WorkManager, diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 6610849ea..4a397a422 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -109,4 +109,8 @@ @string/chapters_read @string/chapters_left + + @string/never + @string/manga_with_downloaded_chapters + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a812f9c9..0c7598e05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -698,4 +698,7 @@ Sign in to set up integration with %s. This will allow you to track your manga reading progress and status Unstable feature This function is experimental. Please make sure you have a backup to avoid data loss + Background downloads + Download new chapters + Manga with downloaded chapters diff --git a/app/src/main/res/xml/pref_tracker.xml b/app/src/main/res/xml/pref_tracker.xml index 37069e2a8..71b113a1e 100644 --- a/app/src/main/res/xml/pref_tracker.xml +++ b/app/src/main/res/xml/pref_tracker.xml @@ -52,6 +52,13 @@ android:summary="@string/disable_nsfw_notifications_summary" android:title="@string/disable_nsfw_notifications" /> + + Date: Sun, 8 Sep 2024 19:22:23 +0530 Subject: [PATCH 04/71] grammar fix --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index acdd1ab05..2178fc652 100644 --- a/README.md +++ b/README.md @@ -53,5 +53,5 @@ install instructions. ### DMCA disclaimer -The developers of this application does not have any affiliation with the content available in the app. -It is collecting from the sources freely available through any web browser. +The developers of this application do not have any affiliation with the content available in the app. +It collects content from sources that are freely available through any web browser From 3fe9ec6918ce46594a4e753623e0ad6f7955ac88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D0=B0=D1=80=20=D0=A0=D0=B0=D0=B7=D0=B8?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Sep 2024 19:09:18 +0200 Subject: [PATCH 05/71] Translated using Weblate (Russian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (689 of 689 strings) Translated using Weblate (Belarusian) Currently translated at 100.0% (689 of 689 strings) Co-authored-by: Макар Разин Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/ Translation: Kotatsu/Strings --- app/src/main/res/values-be/strings.xml | 7 ++++++- app/src/main/res/values-ru/strings.xml | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 47bb92556..71a268b5f 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -679,4 +679,9 @@ Паўтарыць спробу Занадта шмат запытаў. Паспрабуйце яшчэ раз пасля %s Прапусціць усе - + Увайдзіце ў %s, каб працягваць + Нестабільная функцыя + Гэта функцыя эксперыментальная. Пераканайцеся, што ў вас ёсць рэзервовая копія, каб пазбегнуць страты даных + Увайдзіце, каб настроіць інтэграцыю з %s. Гэта дазволіць вам адсочваць прагрэс і статус чытання мангі + Завісла + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 004e54290..50ce2c74a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -680,4 +680,8 @@ Дата Популярность Зависло + Войдите в %s, чтобы продолжить + Нестабильная функция + Войдите, чтобы настроить интеграцию с %s. Это позволит вам отслеживать прогресс и статус чтения манги + Эта функция экспериментальная. Убедитесь, что у вас есть резервная копия, чтобы избежать потери данных \ No newline at end of file From b4592015fb1fee2a44c1c1e4dfc051fa1fe4511e Mon Sep 17 00:00:00 2001 From: Henrique Date: Fri, 13 Sep 2024 19:09:20 +0200 Subject: [PATCH 06/71] Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.6% (666 of 689 strings) Co-authored-by: Henrique Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/ Translation: Kotatsu/Strings --- app/src/main/res/values-pt-rBR/strings.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 77375364d..79f308144 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -28,7 +28,7 @@ Criar atalho… Compartilhar %s Procurar - Procurar mangá + Procurar obra Baixando… Processando… Baixado @@ -659,4 +659,6 @@ Checando por novos logs de capítulos Informações de Debug sobre a checagem de fundo para novos capítulos Plugin incompatível ou erro interno. Certifique-se de que está usando a versão mais recente do plugin e do Kotatsu - + Habilitar filtros em todas as fontes compatíveis + Mostrar filtros + \ No newline at end of file From df17bb5af866d73d2b97dbb7dadfd9f9a30dce36 Mon Sep 17 00:00:00 2001 From: Anon Date: Fri, 13 Sep 2024 19:09:22 +0200 Subject: [PATCH 07/71] Translated using Weblate (Serbian) Currently translated at 100.0% (689 of 689 strings) Co-authored-by: Anon Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/ Translation: Kotatsu/Strings --- app/src/main/res/values-sr/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index f362587be..989d83a21 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -677,4 +677,11 @@ Непопуларно Датум Популарност + Нестабилна карактеристика + Ова функција је експериментална. Увери се да имаш резервну копију да не би дошло до губитка података + Пријави се на %s да би наставио + Пријави се да би подесио интеграцију са %s. Ово ће ти омогућити да пратиш напредак и статус читања манге + Ниска оцена + Узлазно + Силазно \ No newline at end of file From 10109ab2c07f24160a48d104079bcce7fc85b858 Mon Sep 17 00:00:00 2001 From: Amirreza Safavi Date: Fri, 13 Sep 2024 19:09:23 +0200 Subject: [PATCH 08/71] Translated using Weblate (Persian) Currently translated at 77.7% (7 of 9 strings) Translated using Weblate (Persian) Currently translated at 37.4% (258 of 689 strings) Co-authored-by: Amirreza Safavi Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/ Translation: Kotatsu/Strings Translation: Kotatsu/plurals --- app/src/main/res/values-fa/plurals.xml | 8 ++++++++ app/src/main/res/values-fa/strings.xml | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fa/plurals.xml b/app/src/main/res/values-fa/plurals.xml index ac00d3d50..9727602ff 100644 --- a/app/src/main/res/values-fa/plurals.xml +++ b/app/src/main/res/values-fa/plurals.xml @@ -28,4 +28,12 @@ %1$d ماه قبل %1$d ماه قبل + + دقیقه + دقیقه + + + ساعت + ساعت + \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8c8669592..c761e16bf 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -244,4 +244,12 @@ فعال کردن اعلان ها حذف نشانه نشانه ها - + مانگایی یافت نشد که با دسته‌بندی های انتخاب شده تطابق داشته باشد + نشانه اضافه شد + نمای مربعی + از تاریخچه پاک شد + حالت پیشفرض + غیرفعال کردن بهینه سازی باتری + فرستادن + تلاش مجدد + \ No newline at end of file From e48beae3249d2af191b509811d17eb8743d9a627 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 15 Sep 2024 13:30:33 +0300 Subject: [PATCH 09/71] Batch manga fix functionality --- app/src/main/AndroidManifest.xml | 5 +- .../domain/AlternativesUseCase.kt | 22 +- .../alternatives/domain/AutoFixUseCase.kt | 93 +++++++++ .../alternatives/domain/MigrateUseCase.kt | 4 +- .../kotatsu/alternatives/ui/AutoFixService.kt | 196 ++++++++++++++++++ .../org/koitharu/kotatsu/core/model/Manga.kt | 3 + .../kotatsu/core/model/MangaHistory.kt | 1 + .../kotatsu/core/ui/CoroutineIntentService.kt | 56 ++++- .../ui/list/FavouritesListFragment.kt | 6 - .../kotatsu/history/data/EntityMapping.kt | 1 + .../kotatsu/history/ui/HistoryListFragment.kt | 22 +- .../kotatsu/list/ui/MangaListFragment.kt | 25 +++ .../kotatsu/local/ui/ImportService.kt | 6 +- .../drawable-anydpi-v24/ic_stat_auto_fix.xml | 17 ++ .../res/drawable-hdpi/ic_stat_auto_fix.png | Bin 0 -> 631 bytes .../res/drawable-mdpi/ic_stat_auto_fix.png | Bin 0 -> 404 bytes .../res/drawable-xhdpi/ic_stat_auto_fix.png | Bin 0 -> 851 bytes .../res/drawable-xxhdpi/ic_stat_auto_fix.png | Bin 0 -> 1274 bytes app/src/main/res/drawable/ic_auto_fix.xml | 11 + app/src/main/res/drawable/ic_heart.xml | 2 +- .../main/res/drawable/ic_heart_outline.xml | 2 +- app/src/main/res/drawable/ic_save.xml | 2 +- app/src/main/res/menu/mode_favourites.xml | 6 + app/src/main/res/menu/mode_history.xml | 6 + app/src/main/res/values/strings.xml | 6 + 25 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png create mode 100644 app/src/main/res/drawable/ic_auto_fix.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfdc81f88..c0f146385 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ - + @@ -273,6 +273,9 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index a993eb31c..ed5444bcf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -18,14 +18,16 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject private const val MAX_PARALLELISM = 4 -private const val MATCH_THRESHOLD = 0.2f +private const val MATCH_THRESHOLD_DEFAULT = 0.2f class AlternativesUseCase @Inject constructor( private val sourcesRepository: MangaSourcesRepository, private val mangaRepositoryFactory: MangaRepository.Factory, ) { - suspend operator fun invoke(manga: Manga): Flow { + suspend operator fun invoke(manga: Manga): Flow = invoke(manga, MATCH_THRESHOLD_DEFAULT) + + suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow { val sources = getSources(manga.source) if (sources.isEmpty()) { return emptyFlow() @@ -44,7 +46,7 @@ class AlternativesUseCase @Inject constructor( } }.getOrDefault(emptyList()) for (item in list) { - if (item.matches(manga)) { + if (item.matches(manga, matchThreshold)) { send(item) } } @@ -65,16 +67,16 @@ class AlternativesUseCase @Inject constructor( return result } - private fun Manga.matches(ref: Manga): Boolean { - return matchesTitles(title, ref.title) || - matchesTitles(title, ref.altTitle) || - matchesTitles(altTitle, ref.title) || - matchesTitles(altTitle, ref.altTitle) + private fun Manga.matches(ref: Manga, threshold: Float): Boolean { + return matchesTitles(title, ref.title, threshold) || + matchesTitles(title, ref.altTitle, threshold) || + matchesTitles(altTitle, ref.title, threshold) || + matchesTitles(altTitle, ref.altTitle, threshold) } - private fun matchesTitles(a: String?, b: String?): Boolean { - return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD) + private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean { + return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold) } private fun MangaSource.priority(ref: MangaSource): Int { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt new file mode 100644 index 000000000..ec45258c9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.alternatives.domain + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.koitharu.kotatsu.core.model.chaptersCount +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class AutoFixUseCase @Inject constructor( + private val mangaRepositoryFactory: MangaRepository.Factory, + private val alternativesUseCase: AlternativesUseCase, + private val migrateUseCase: MigrateUseCase, + private val mangaDataRepository: MangaDataRepository, +) { + + suspend operator fun invoke(mangaId: Long): Pair { + val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" } + .getDetailsSafe() + if (seed.isHealthy()) { + return seed to null // no fix required + } + val replacement = alternativesUseCase(seed, matchThreshold = 0.02f) + .filter { it.isHealthy() } + .runningFold(null) { best, candidate -> + if (best == null || best < candidate) { + candidate + } else { + best + } + }.selectLastWithTimeout(4, 40, TimeUnit.SECONDS) + migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed))) + return seed to replacement + } + + private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable { + val repo = mangaRepositoryFactory.create(source) + val details = if (this.chapters != null) this else repo.getDetails(this) + val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false + val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first()) + pageUrl.toHttpUrlOrNull() != null + }.getOrDefault(false) + + private suspend fun Manga.getDetailsSafe() = runCatchingCancellable { + mangaRepositoryFactory.create(source).getDetails(this) + }.getOrDefault(this) + + private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount()) + + @Suppress("UNCHECKED_CAST", "OPT_IN_USAGE") + private suspend fun Flow.selectLastWithTimeout( + minCount: Int, + timeout: Long, + timeUnit: TimeUnit + ): T? = channelFlow { + var lastValue: T? = null + launch { + delay(timeUnit.toMillis(timeout)) + close(InternalTimeoutException(lastValue)) + } + withIndex().transformWhile { (index, value) -> + lastValue = value + emit(value) + index < minCount && !isClosedForSend + }.collect { + send(it) + } + }.catch { e -> + if (e is InternalTimeoutException) { + emit(e.value as T?) + } else { + throw e + } + }.lastOrNull() + + class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException() + + private class InternalTimeoutException(val value: Any?) : CancellationException() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt index 2e9caf19d..df5dd5233 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/MigrateUseCase.kt @@ -136,7 +136,7 @@ constructor( return HistoryEntity( mangaId = newManga.id, createdAt = history.createdAt, - updatedAt = System.currentTimeMillis(), + updatedAt = history.updatedAt, chapterId = currentChapter.id, page = history.page, scroll = history.scroll, @@ -173,7 +173,7 @@ constructor( return HistoryEntity( mangaId = newManga.id, createdAt = history.createdAt, - updatedAt = System.currentTimeMillis(), + updatedAt = history.updatedAt, chapterId = newChapterId, page = history.page, scroll = history.scroll, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt new file mode 100644 index 000000000..9fa0fdf42 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt @@ -0,0 +1,196 @@ +package org.koitharu.kotatsu.alternatives.ui + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import coil.ImageLoader +import coil.request.ImageRequest +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase +import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class AutoFixService : CoroutineIntentService() { + + @Inject + lateinit var autoFixUseCase: AutoFixUseCase + + @Inject + lateinit var coil: ImageLoader + + private lateinit var notificationManager: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(applicationContext) + } + + override suspend fun processIntent(startId: Int, intent: Intent) { + val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) + startForeground(startId) + try { + for (mangaId in ids) { + val result = runCatchingCancellable { + autoFixUseCase.invoke(mangaId) + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(result) + notificationManager.notify(TAG, startId, notification) + } + } + } finally { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + } + + override fun onError(startId: Int, error: Throwable) { + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = runBlocking { buildNotification(Result.failure(error)) } + notificationManager.notify(TAG, startId, notification) + } + } + + @SuppressLint("InlinedApi") + private fun startForeground(startId: Int) { + val title = applicationContext.getString(R.string.fixing_manga) + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) + .setName(title) + .setShowBadge(false) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress(0, 0, true) + .setSmallIcon(R.drawable.ic_stat_auto_fix) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + materialR.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(startId), + ) + .build() + + ServiceCompat.startForeground( + this, + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private suspend fun buildNotification(result: Result>): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + result.onSuccess { (seed, replacement) -> + if (replacement != null) { + notification.setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(replacement.coverUrl) + .tag(replacement.source) + .build(), + ).toBitmapOrNull(), + ) + notification.setSubText(replacement.title) + val intent = DetailsActivity.newIntent(applicationContext, replacement) + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + replacement.id.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ).setVisibility( + if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC, + ) + notification + .setContentTitle(applicationContext.getString(R.string.fixed)) + .setContentText( + applicationContext.getString( + R.string.manga_replaced, + seed.title, + seed.source.getTitle(applicationContext), + replacement.title, + replacement.source.getTitle(applicationContext), + ), + ) + .setSmallIcon(R.drawable.ic_stat_done) + } else { + notification + .setContentTitle(applicationContext.getString(R.string.fixing_manga)) + .setContentText(applicationContext.getString(R.string.no_fix_required, seed.title)) + .setSmallIcon(android.R.drawable.stat_sys_warning) + } + }.onFailure { error -> + notification + .setContentTitle(applicationContext.getString(R.string.error_occurred)) + .setContentText( + if (error is AutoFixUseCase.NoAlternativesException) { + applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title) + } else { + error.getDisplayMessage(applicationContext.resources) + }, + ) + .setSmallIcon(android.R.drawable.stat_notify_error) + .addAction( + R.drawable.ic_alert_outline, + applicationContext.getString(R.string.report), + ErrorReporterReceiver.getPendingIntent(applicationContext, error), + ) + } + return notification.build() + } + + companion object { + + private const val DATA_IDS = "ids" + private const val TAG = "auto_fix" + private const val CHANNEL_ID = "auto_fix" + private const val FOREGROUND_NOTIFICATION_ID = 38 + + fun start(context: Context, mangaIds: Collection): Boolean = try { + val intent = Intent(context, AutoFixService::class.java) + intent.putExtra(DATA_IDS, mangaIds.toLongArray()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 8a3674fa8..4114c6f58 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -110,6 +110,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? { val Manga.isLocal: Boolean get() = source == LocalMangaSource +val Manga.isBroken: Boolean + get() = source == UnknownMangaSource + val Manga.appUrl: Uri get() = Uri.parse("https://kotatsu.app/manga").buildUpon() .appendQueryParameter("source", source.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt index d72ed9d3a..21f1a1349 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt @@ -12,4 +12,5 @@ data class MangaHistory( val page: Int, val scroll: Int, val percent: Float, + val chaptersCount: Int, ) : Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index e8f167f32..5441134cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -1,12 +1,21 @@ package org.koitharu.kotatsu.core.ui +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.os.PatternMatcher import androidx.annotation.AnyThread import androidx.annotation.WorkerThread +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - launchCoroutine(intent, startId) + val job = launchCoroutine(intent, startId) + val receiver = CancelReceiver(job) + ContextCompat.registerReceiver( + this, + receiver, + createIntentFilter(this, startId), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + job.invokeOnCompletion { unregisterReceiver(receiver) } return START_REDELIVER_INTENT } @@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() { @AnyThread protected abstract fun onError(startId: Int, error: Throwable) + protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( + this, + 0, + createCancelIntent(this, startId), + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ) + private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable -> throwable.printStackTraceDebug() onError(startId, throwable) } + + private class CancelReceiver( + private val job: Job + ) : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + job.cancel() + } + } + + private companion object { + + private const val SCHEME = "startid" + private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL" + + fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter { + val intentFilter = IntentFilter(cancelAction(service)) + intentFilter.addDataScheme(SCHEME) + intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL) + return intentFilter + } + + fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent { + return Intent(cancelAction(service)) + .setData("$SCHEME://$startId".toUri()) + } + + private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 6a08dbcfa..0f632f025 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -10,7 +10,6 @@ import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.withArgs @@ -58,11 +57,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis return super.onCreateActionMode(controller, mode, menu) } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal } - return super.onPrepareActionMode(controller, mode, menu) - } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt index 664fbbc0d..436fc1657 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt @@ -10,4 +10,5 @@ fun HistoryEntity.toMangaHistory() = MangaHistory( page = page, scroll = scroll.toInt(), percent = percent, + chaptersCount = chaptersCount, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index f9f54e021..da7a1d1c6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -5,10 +5,9 @@ import android.view.Menu import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator @@ -40,11 +39,6 @@ class HistoryListFragment : MangaListFragment() { return super.onCreateActionMode(controller, mode, menu) } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal } - return super.onPrepareActionMode(controller, mode, menu) - } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { @@ -54,14 +48,16 @@ class HistoryListFragment : MangaListFragment() { } R.id.action_mark_current -> { - MaterialAlertDialogBuilder(context ?: return false) - .setTitle(item.title) - .setMessage(R.string.mark_as_completed_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok) { _, _ -> + buildAlertDialog(context ?: return false, isCentered = true) { + setTitle(item.title) + setIcon(item.icon) + setMessage(R.string.mark_as_completed_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(android.R.string.ok) { _, _ -> viewModel.markAsRead(selectedItems) mode.finish() - }.show() + } + }.show() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 57b78605d..3a9f02614 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -20,11 +20,14 @@ import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.ui.AutoFixService import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.core.ui.list.ListSelectionController @@ -278,6 +281,14 @@ abstract class MangaListFragment : } } + @CallSuper + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + val hasNoLocal = selectedItems.none { it.isLocal } + menu.findItem(R.id.action_save)?.isVisible = hasNoLocal + menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal + return super.onPrepareActionMode(controller, mode, menu) + } + override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { return menu.isNotEmpty() } @@ -310,6 +321,20 @@ abstract class MangaListFragment : true } + R.id.action_fix -> { + buildAlertDialog(context ?: return false, isCentered = true) { + setTitle(item.title) + setIcon(item.icon) + setMessage(R.string.manga_fix_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.fix) { _, _ -> + AutoFixService.start(context, selectedItemsIds) + mode.finish() + } + }.show() + true + } + else -> false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt index 45003d292..f033847c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.local.ui +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.Context @@ -47,7 +48,7 @@ class ImportService : CoroutineIntentService() { } override suspend fun processIntent(startId: Int, intent: Intent) { - val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No unput uri" } + val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" } startForeground() try { val result = runCatchingCancellable { @@ -69,7 +70,8 @@ class ImportService : CoroutineIntentService() { } } - private suspend fun startForeground() { + @SuppressLint("InlinedApi") + private fun startForeground() { val title = applicationContext.getString(R.string.importing_manga) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(title) diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml new file mode 100644 index 000000000..7c78d96b8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_auto_fix.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-hdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..08f66829c1d293e854a736fdaeb6b80611ecd2be GIT binary patch literal 631 zcmV--0*L*IP)HjorQmd#R51Qf z4shHDe05Lk2y~0%Q6TXT)YR)^V!6w{5b$2ooc!wKW^WWoyr~N$m9 zz7X&p*PQ3j1!&U@*cS!zYS0SB%Fu=zO9S3ERHfJz=#CqULLwh+1H5mbALcIboR2(f z=LU;6%{GXA0{5Yd?3oxS{z{v9V*WMsTgT_wTR15CQHwX6*c|jz?463*zOjH}FSMD5 zp1F$Hm*5u96#@!<&}Iz(Z4F{;I`^LE#tw?Sbec)H?94yVSpWPh6d7!jk0t1lUcctt zv4bM(PVqFNUz#I>A|0+8AbOi*)B_fYEv_2CduI@T2c2=o=IFUCqkN!A{5EwsHvw~p z)bma38MbW(?8`(3$3YgE+XT-l<Fu{#6Ar%L~WBn?CF1+;|9d9aqJ(MJUU!6NQ-j| z1I0dbb)xv=z(wrs{mxw|-j`nCi2aV?rk=5H;-HDHHuIL}rUsgs=h%#C^A}%>VCxTi R*AM^z002ovPDHLkV1hhwCr1DP literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-mdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..47978e54385a35095180a9f6dd288fc7b32df8ca GIT binary patch literal 404 zcmV;F0c-w=P)YLCK>(;2kyn?8L9L zcTo#A&!9J7Uf|XTXkhb|q2$YyYqC434F%7((c`|Npq%xkj$_J71p>z+cxiDzNvEYN9PRms)Cv)o7YJGm2IO#N&QuXeF2{~tHGa-%qHzD yb-)X1*=#Lbk>o%9g1*g+4W-lSfYIChmwW>@f*|o7oMLSj#9sHG*c6PiHWx4) z3yj|&)`-1(7=9o+SnmpY&jvp45nYJu!Dquf)_WoL+c&JE{2atb#8sVl1%2&-?*T-! z%A51OL_F4dZ(!7?Pr#tIT4EM5Fd9Ifl417=xU*j_C zN(lztqlUhLIITX9u)b7az{U2o--mtZW-o&{+(w~k z=bRTX+D}8dvWvZHptcb)f@q7VEk!)%ycaMU)DvZ(hrJlFj~C_@vR~z%1^HgB^#Ue` z)IeF-%3e`GH(&Lbx3U@^SeqFMudrF@W3MEjf5HY}3F|TegX8Sz$Ts#$L+tLJ#_SKp zzNw`m{+CVaHP49M<$KFUV34&=pz#PhIZ|OS7o`3;Vzz9|o$hJf^3T24M9^?jHD|%= z*#>=WLI0>ZR{3ROw{PxfeInxjgRQWaE9mW)gl`$O4dSo`UnM+~GqHfWbRC d*Py{a!Y?H=iG&r5gZ}^k002ovPDHLkV1mM;o^b#G literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png b/app/src/main/res/drawable-xxhdpi/ic_stat_auto_fix.png new file mode 100644 index 0000000000000000000000000000000000000000..9df83c85a094b44f8296a3341fdb75dbbf4aaeb4 GIT binary patch literal 1274 zcmVt*7+gJ zr!0$9r_g2-%LSDugTd~^|2(C7gYrjMu6mp0s&5PHyou#sU;nFiU10No*BcvDpA@+N zV8&l8k9qlhEWZT0@6qz|BRM!bXs@a&6qLzA<6_7bMU>RF@0} zJC2G6R%<-V_hp@E0_*$7lBDt?;D+K(1b0|6P;$qVLBF{wQ{WE3M_4{qT}YL>im*hf ztTV9jKVlR+#?*eK(M#ZlY!NqRiJnO?0Y6Am_Q|y!Bj&5$tbiT=Rg$sK%byjwQE5@= z4ag_w9nn86dfRtJT?uC_$PcUCYGY9TlGxcUeW}{@3U=@}>GUUR6GHuSVs8i2e%?v! z*dgicdumez?OqVSJ!;c=kl*Kgt~O=R{_DW^C)IZv6zp@L7L5 z9c5p)>BkJ$5|SEUYw9l%yvK+8+AaKms`k;O5X7I5Qzd5s+`h+FuqCQD z;O3|IeLLP%eQj9DQD0ds4rz>7z-?I9sk{id-=Nz$1ZGciGT7-=nuRg+e^`x!onM6VQ}RjmnRPG;D>W;gBWgQqf%_G6_>4E%ub4Xx k6LXKs1_lNO1_q|ee|;OYP27M0!vFvP07*qoM6N<$f+jI%!2kdN literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_auto_fix.xml b/app/src/main/res/drawable/ic_auto_fix.xml new file mode 100644 index 000000000..3c63ca799 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_fix.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml index 3d86975e1..a39f5cb42 100644 --- a/app/src/main/res/drawable/ic_heart.xml +++ b/app/src/main/res/drawable/ic_heart.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_heart_outline.xml b/app/src/main/res/drawable/ic_heart_outline.xml index 79f083a80..79caa9949 100644 --- a/app/src/main/res/drawable/ic_heart_outline.xml +++ b/app/src/main/res/drawable/ic_heart_outline.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml index 8ec39892f..1d98b66a1 100644 --- a/app/src/main/res/drawable/ic_save.xml +++ b/app/src/main/res/drawable/ic_save.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/menu/mode_favourites.xml b/app/src/main/res/menu/mode_favourites.xml index 4c5d3897e..347d3cd26 100644 --- a/app/src/main/res/menu/mode_favourites.xml +++ b/app/src/main/res/menu/mode_favourites.xml @@ -21,6 +21,12 @@ android:title="@string/save" app:showAsAction="ifRoom|withText" /> + + + + Background downloads Download new chapters Manga with downloaded chapters + Manga %1$s(%2$s) replaced with %3$s(%4$s) + Fixing manga + Fixed successfully + No fix required for %s + No alternatives found for %s + This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background From fe2bb05895092a5e3f844212c5ca4698f2cde9f9 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 15 Sep 2024 15:11:49 +0300 Subject: [PATCH 10/71] Update dependencies --- app/build.gradle | 12 ++++++------ app/src/main/res/values/strings.xml | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fcfd591d0..6a1eff729 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -89,7 +89,7 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core-ktx:1.13.1' @@ -125,7 +125,7 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' - implementation 'com.squareup.okio:okio:3.9.0' + implementation 'com.squareup.okio:okio:3.9.1' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' @@ -141,8 +141,8 @@ dependencies { implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'io.noties.markwon:core:4.6.2' - implementation 'ch.acra:acra-http:5.11.3' - implementation 'ch.acra:acra-dialog:5.11.3' + implementation 'ch.acra:acra-http:5.11.4' + implementation 'ch.acra:acra-dialog:5.11.4' implementation 'org.conscrypt:conscrypt-android:2.5.2' @@ -151,14 +151,14 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20240303' - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' androidTestImplementation 'androidx.test:runner:1.6.1' androidTestImplementation 'androidx.test:rules:1.6.1' androidTestImplementation 'androidx.test:core-ktx:1.6.1' androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' - androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1' diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13be7ae09..101eef40e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -701,10 +701,10 @@ Background downloads Download new chapters Manga with downloaded chapters - Manga %1$s(%2$s) replaced with %3$s(%4$s) + Manga \"%1$s\" (%2$s) replaced with \"%3$s\" (%4$s) Fixing manga Fixed successfully - No fix required for %s - No alternatives found for %s + No fix required for \"%s\" + No alternatives found for \"%s\" This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background From ad21321a1d8b3e176051991171945456e95c73da Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 15 Sep 2024 15:38:52 +0300 Subject: [PATCH 11/71] Update parsers --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6a1eff729..adb776406 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 668 - versionName = '7.5.2' + versionCode = 669 + versionName = '7.6-a1' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:ad726a3fd7') { + implementation('com.github.KotatsuApp:kotatsu-parsers:aba8a80d8f') { exclude group: 'org.json', module: 'json' } From 9ab7159cb96225081b63a63a36dcfada14563bc6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 21 Sep 2024 08:22:32 +0300 Subject: [PATCH 12/71] Update parsers and filters --- app/build.gradle | 12 +- .../domain/AlternativesUseCase.kt | 5 +- .../kotatsu/core/model/GenericSortOrder.kt | 1 + .../kotatsu/core/model/MangaSource.kt | 3 + .../kotatsu/core/parser/DummyParser.kt | 12 +- .../core/parser/EmptyMangaRepository.kt | 26 +- .../kotatsu/core/parser/MangaLinkResolver.kt | 4 +- .../kotatsu/core/parser/MangaRepository.kt | 24 +- .../core/parser/ParserMangaRepository.kt | 37 +- .../external/ExternalMangaRepository.kt | 53 +- .../external/ExternalPluginContentSource.kt | 25 +- .../kotatsu/core/ui/model/SortOrder.kt | 24 + .../koitharu/kotatsu/core/util/ext/Flow.kt | 3 + .../kotatsu/core/util/ext/Primitive.kt | 2 - .../explore/domain/ExploreRepository.kt | 7 +- .../explore/domain/RecoverMangaUseCase.kt | 2 +- .../kotatsu/filter/ui/FilterCoordinator.kt | 725 +++++++----------- .../kotatsu/filter/ui/FilterHeaderFragment.kt | 18 +- .../kotatsu/filter/ui/FilterHeaderProducer.kt | 75 ++ .../koitharu/kotatsu/filter/ui/FilterOwner.kt | 6 - .../koitharu/kotatsu/filter/ui/MangaFilter.kt | 35 - .../filter/ui/OnFilterChangedListener.kt | 23 - .../kotatsu/filter/ui/model/FilterProperty.kt | 44 +- .../filter/ui/sheet/FilterSheetFragment.kt | 77 +- .../filter/ui/tags/TagTitleComparator.kt | 16 + .../filter/ui/tags/TagsCatalogSheet.kt | 4 +- .../filter/ui/tags/TagsCatalogViewModel.kt | 43 +- .../kotatsu/list/ui/model/ListModelExt.kt | 2 +- .../list/ui/preview/PreviewFragment.kt | 6 +- .../local/data/LocalMangaRepository.kt | 64 +- .../local/domain/DeleteLocalMangaUseCase.kt | 2 +- .../local/domain/DeleteReadChaptersUseCase.kt | 2 +- .../kotatsu/local/ui/LocalListFragment.kt | 9 +- .../kotatsu/local/ui/LocalListViewModel.kt | 8 +- .../kotatsu/reader/ui/ReaderViewModel.kt | 7 +- .../reader/ui/pager/standard/PageHolder.kt | 2 +- .../reader/ui/pager/webtoon/WebtoonHolder.kt | 2 +- .../remotelist/ui/RemoteListFragment.kt | 13 +- .../remotelist/ui/RemoteListViewModel.kt | 26 +- .../selector/ScrobblingSelectorViewModel.kt | 1 + .../search/domain/MangaSearchRepository.kt | 4 + .../kotatsu/search/ui/MangaListActivity.kt | 36 +- .../kotatsu/search/ui/SearchViewModel.kt | 5 +- .../search/ui/multi/MultiSearchViewModel.kt | 4 +- .../suggestions/ui/SuggestionsWorker.kt | 7 +- app/src/main/res/layout/sheet_filter.xml | 61 +- app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 12 + 48 files changed, 800 insertions(+), 780 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt diff --git a/app/build.gradle b/app/build.gradle index adb776406..b23267ead 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:aba8a80d8f') { + implementation('com.github.KotatsuApp:kotatsu-parsers:336c4a4d49') { exclude group: 'org.json', module: 'json' } @@ -96,10 +96,10 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.9.2' implementation 'androidx.fragment:fragment-ktx:1.8.3' implementation 'androidx.transition:transition-ktx:1.5.1' - implementation 'androidx.collection:collection-ktx:1.4.3' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5' - implementation 'androidx.lifecycle:lifecycle-service:2.8.5' - implementation 'androidx.lifecycle:lifecycle-process:2.8.5' + implementation 'androidx.collection:collection-ktx:1.4.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' + implementation 'androidx.lifecycle:lifecycle-service:2.8.6' + implementation 'androidx.lifecycle:lifecycle-process:2.8.6' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' @@ -107,7 +107,7 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6' implementation 'androidx.webkit:webkit:1.11.0' implementation 'androidx.work:work-runtime:2.9.1' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index ed5444bcf..e822efbbd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @@ -36,13 +37,13 @@ class AlternativesUseCase @Inject constructor( return channelFlow { for (source in sources) { val repository = mangaRepositoryFactory.create(source) - if (!repository.isSearchSupported) { + if (!repository.filterCapabilities.isSearchSupported) { continue } launch { val list = runCatchingCancellable { semaphore.withPermit { - repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) + repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) } }.getOrDefault(emptyList()) for (item in list) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt index 34d5a169b..389dc08b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/GenericSortOrder.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.model.SortOrder +@Deprecated("") enum class GenericSortOrder( @StringRes val titleResId: Int, val ascending: SortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 5e847ad11..66c348689 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -56,6 +56,9 @@ val ContentType.titleResId ContentType.HENTAI -> R.string.content_type_hentai ContentType.COMICS -> R.string.content_type_comics ContentType.OTHER -> R.string.content_type_other + ContentType.MANHWA -> R.string.content_type_manhwa + ContentType.MANHUA -> R.string.content_type_manhua + ContentType.NOVEL -> R.string.content_type_novel } fun MangaSource.getSummary(context: Context): String? = when (this) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt index 8bad91af4..74371571c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet @@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse override val availableSortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities() + + override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) + override suspend fun getDetails(manga: Manga): Manga = stub(manga) - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub(null) + override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List = stub(null) override suspend fun getPages(chapter: MangaChapter): List = stub(null) - override suspend fun getAvailableTags(): Set = stub(null) - private fun stub(manga: Manga?): Nothing { throw UnsupportedSourceException("Usage of Dummy parser", manga) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt index 833b87edd..af90994f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/EmptyMangaRepository.kt @@ -1,37 +1,29 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet -import java.util.Locale class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { override val sortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) - override val states: Set - get() = emptySet() - override val contentRatings: Set - get() = emptySet() + override var defaultSortOrder: SortOrder get() = SortOrder.NEWEST set(value) = Unit - override val isMultipleTagsSupported: Boolean - get() = false - override val isTagsExclusionSupported: Boolean - get() = false - override val isSearchSupported: Boolean - get() = false - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub(null) + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities() + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = stub(null) override suspend fun getDetails(manga: Manga): Manga = stub(manga) @@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository { override suspend fun getPageUrl(page: MangaPage): String = stub(null) - override suspend fun getTags(): Set = stub(null) - - override suspend fun getLocales(): Set = stub(null) + override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) override suspend fun getRelated(seed: Manga): List = stub(seed) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt index 065529cc2..54bdced1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt @@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor( private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { if (!title.isNullOrEmpty()) { - val list = getList(0, MangaListFilter.Search(title)) + val list = getList(0, null, MangaListFilter(query = title)) if (url != null) { list.find { it.url == url }?.let { return it @@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor( }.ifNullOrEmpty { seed.author } ?: return@runCatchingCancellable null - val seedList = getList(0, MangaListFilter.Search(seedTitle)) + val seedList = getList(0, null, MangaListFilter(query = seedTitle)) seedList.first { x -> x.url == url } }.getOrThrow() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index ae2bef8a9..580113c83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.set @@ -35,19 +33,11 @@ interface MangaRepository { val sortOrders: Set - val states: Set - - val contentRatings: Set - var defaultSortOrder: SortOrder - val isMultipleTagsSupported: Boolean + val filterCapabilities: MangaListFilterCapabilities - val isTagsExclusionSupported: Boolean - - val isSearchSupported: Boolean - - suspend fun getList(offset: Int, filter: MangaListFilter?): List + suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List suspend fun getDetails(manga: Manga): Manga @@ -55,14 +45,12 @@ interface MangaRepository { suspend fun getPageUrl(page: MangaPage): String - suspend fun getTags(): Set - - suspend fun getLocales(): Set + suspend fun getFilterOptions(): MangaListFilterOptions suspend fun getRelated(seed: Manga): List suspend fun find(manga: Manga): Manga? { - val list = getList(0, MangaListFilter.Search(manga.title)) + val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) return list.find { x -> x.id == manga.id } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt index c54e35d97..82a614891 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt @@ -13,11 +13,14 @@ import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.Locale @@ -28,17 +31,20 @@ class ParserMangaRepository( cache: MemoryContentCache, ) : CachingMangaRepository(cache), Interceptor { + private val filterOptionsLazy = SuspendLazy { + mirrorSwitchInterceptor.withMirrorSwitching { + parser.getFilterOptions() + } + } + override val source: MangaParserSource get() = parser.source override val sortOrders: Set get() = parser.availableSortOrders - override val states: Set - get() = parser.availableStates - - override val contentRatings: Set - get() = parser.availableContentRating + override val filterCapabilities: MangaListFilterCapabilities + get() = parser.filterCapabilities override var defaultSortOrder: SortOrder get() = getConfig().defaultSortOrder ?: sortOrders.first() @@ -46,15 +52,6 @@ class ParserMangaRepository( getConfig().defaultSortOrder = value } - override val isMultipleTagsSupported: Boolean - get() = parser.isMultipleTagsSupported - - override val isSearchSupported: Boolean - get() = parser.isSearchSupported - - override val isTagsExclusionSupported: Boolean - get() = parser.isTagsExclusionSupported - var domain: String get() = parser.domain set(value) { @@ -72,9 +69,9 @@ class ParserMangaRepository( } } - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { return mirrorSwitchInterceptor.withMirrorSwitching { - parser.getList(offset, filter) + parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } } @@ -88,13 +85,7 @@ class ParserMangaRepository( parser.getPageUrl(page) } - override suspend fun getTags(): Set = mirrorSwitchInterceptor.withMirrorSwitching { - parser.getAvailableTags() - } - - override suspend fun getLocales(): Set { - return parser.getAvailableLocales() - } + override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { parser.getFavicons() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt index c888495e4..148e5abff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt @@ -6,16 +6,14 @@ import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.util.EnumSet -import java.util.Locale class ExternalMangaRepository( private val contentResolver: ContentResolver, @@ -36,28 +34,39 @@ class ExternalMangaRepository( override val sortOrders: Set get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) - override val states: Set - get() = capabilities?.availableStates.orEmpty() - - override val contentRatings: Set - get() = capabilities?.availableContentRating.orEmpty() + override val filterCapabilities: MangaListFilterCapabilities + get() = capabilities.let { + MangaListFilterCapabilities( + isMultipleTagsSupported = it?.isMultipleTagsSupported == true, + isTagsExclusionSupported = it?.isTagsExclusionSupported == true, + isSearchSupported = it?.isSearchSupported == true, + isSearchWithFiltersSupported = false, // TODO + isYearSupported = false, // TODO + isYearRangeSupported = false, // TODO + isOriginalLocaleSupported = false, // TODO + ) + } override var defaultSortOrder: SortOrder get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL set(value) = Unit - override val isMultipleTagsSupported: Boolean - get() = capabilities?.isMultipleTagsSupported ?: true + override suspend fun getFilterOptions(): MangaListFilterOptions = capabilities.let { + MangaListFilterOptions( + availableTags = runInterruptible(Dispatchers.IO) { + contentSource.getTags() + }, + availableStates = it?.availableStates.orEmpty(), + availableContentRating = it?.availableContentRating.orEmpty(), + availableContentTypes = emptySet(), + availableDemographics = emptySet(), + availableLocales = emptySet(), + ) + } - override val isTagsExclusionSupported: Boolean - get() = capabilities?.isTagsExclusionSupported ?: false - - override val isSearchSupported: Boolean - get() = capabilities?.isSearchSupported ?: true - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = runInterruptible(Dispatchers.IO) { - contentSource.getList(offset, filter) + contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) } override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) { @@ -70,11 +79,5 @@ class ExternalMangaRepository( override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO - override suspend fun getTags(): Set = runInterruptible(Dispatchers.IO) { - contentSource.getTags() - } - - override suspend fun getLocales(): Set = emptySet() // TODO - override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() // TODO } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt index 4bb2c666f..c032b05a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt @@ -31,25 +31,18 @@ class ExternalPluginContentSource( @Blocking @WorkerThread - fun getList(offset: Int, filter: MangaListFilter?): List { + fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { val uri = "content://${source.authority}/manga".toUri().buildUpon() uri.appendQueryParameter("offset", offset.toString()) - when (filter) { - is MangaListFilter.Advanced -> { - filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } - filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } - filter.states.forEach { uri.appendQueryParameter("state", it.name) } - filter.locale?.let { uri.appendQueryParameter("locale", it.language) } - filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } - } - - is MangaListFilter.Search -> { - uri.appendQueryParameter("query", filter.query) - } - - null -> Unit + filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") } + filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") } + filter.states.forEach { uri.appendQueryParameter("state", it.name) } + filter.locale?.let { uri.appendQueryParameter("locale", it.language) } + filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) } + if (!filter.query.isNullOrEmpty()) { + uri.appendQueryParameter("query", filter.query) } - return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name) + return contentResolver.query(uri.build(), null, null, null, order.name) .safe() .use { cursor -> val result = ArrayList(cursor.count) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt index e324b1a4a..f8558d3a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt @@ -4,14 +4,22 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED +import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK +import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR import org.koitharu.kotatsu.parsers.model.SortOrder.RATING import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC +import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC @@ -28,6 +36,14 @@ val SortOrder.titleRes: Int POPULARITY_ASC -> R.string.unpopular RATING_ASC -> R.string.low_rating NEWEST_ASC -> R.string.order_oldest + ADDED -> R.string.recently_added + ADDED_ASC -> R.string.added_long_ago + RELEVANCE -> R.string.by_relevance + POPULARITY_HOUR -> R.string.popular_in_hour + POPULARITY_TODAY -> R.string.popular_today + POPULARITY_WEEK -> R.string.popular_in_week + POPULARITY_MONTH -> R.string.popular_in_month + POPULARITY_YEAR -> R.string.popular_in_year } val SortOrder.direction: SortDirection @@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection POPULARITY_ASC, RATING_ASC, NEWEST_ASC, + ADDED_ASC, ALPHABETICAL -> SortDirection.ASC UPDATED, POPULARITY, + POPULARITY_HOUR, + POPULARITY_TODAY, + POPULARITY_WEEK, + POPULARITY_MONTH, + POPULARITY_YEAR, RATING, NEWEST, + ADDED, + RELEVANCE, ALPHABETICAL_DESC -> SortDirection.DESC } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index 2ebd4ce7f..a8db54adc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -132,3 +133,5 @@ suspend fun Flow.firstNotNull(): T = checkNotNull(first { x -> x ! suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } fun Flow>.flattenLatest() = flatMapLatest { it } + +fun SuspendLazy.asFlow() = flow { emit(tryGet()) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt index 4068968a4..78ba499fc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.core.util.ext -inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this - inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this fun longOf(a: Int, b: Int): Long { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index 19446b1ee..f09f89f72 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -70,15 +70,14 @@ class ExploreRepository @Inject constructor( ): List = runCatchingCancellable { val repository = mangaRepositoryFactory.create(source) val order = repository.sortOrders.random() - val availableTags = repository.getTags() + val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x.title.almostEquals(title, 0.4f) } } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), + order = order, + filter = MangaListFilter(tags = setOfNotNull(tag)) ).asArrayList() if (settings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt index fbdb0f045..cb61e7f72 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt @@ -19,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor( return@runCatchingCancellable null } val repository = repositoryFactory.create(manga.source) - val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) + val list = repository.getList(offset = 0, null, MangaListFilter(query = manga.title)) val newManga = list.find { x -> x.title == manga.title }?.let { repository.getDetails(it) } ?: return@runCatchingCancellable null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 529a1094d..c2680c6de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -1,272 +1,300 @@ package org.koitharu.kotatsu.filter.ui -import android.view.View import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.GenericSortOrder import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.SortDirection -import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.model.direction -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.asFlow import org.koitharu.kotatsu.core.util.ext.lifecycleScope -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal -import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.core.util.ext.sortedWithSafe 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.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorFooter +import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.YEAR_MIN import org.koitharu.kotatsu.parsers.util.SuspendLazy -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import java.text.Collator -import java.util.EnumSet -import java.util.LinkedList +import java.util.Calendar import java.util.Locale -import java.util.TreeSet import javax.inject.Inject @ViewModelScoped class FilterCoordinator @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - dataRepository: MangaDataRepository, private val searchRepository: MangaSearchRepository, lifecycle: ViewModelLifecycle, -) : MangaFilter { +) { - private val coroutineScope = lifecycle.lifecycleScope + private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default private val repository = mangaRepositoryFactory.create(MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])) - private val currentState = MutableStateFlow( - MangaListFilter.Advanced( - sortOrder = repository.defaultSortOrder, - tags = emptySet(), - tagsExclude = emptySet(), - locale = null, - states = emptySet(), - contentRating = emptySet(), - ), - ) - private val localTags = SuspendLazy { - dataRepository.findTags(repository.source) - } - private val tagsFlow = flow { - val localTags = localTags.get() - emit(PendingData(localTags, isLoading = true, error = null)) - tryLoadTags() - .onSuccess { remoteTags -> - emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null)) - }.onFailure { - emit(PendingData(localTags, isLoading = false, error = it)) - } - }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), PendingData(emptySet(), true, null)) - private var availableTagsDeferred = loadTagsAsync() - private var availableLocalesDeferred = loadLocalesAsync() - private var allTagsLoadJob: Job? = null + private val sourceLocale = (repository.source as? MangaParserSource)?.locale - override val allTags = MutableStateFlow>(listOf(LoadingState)) - get() { - if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) { - loadAllTags() - } - return field - } + private val currentListFilter = MutableStateFlow(MangaListFilter.EMPTY) + private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder) - override val filterTags: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.tags }, - getTopTagsAsFlow(currentState.map { it.tags }, 16), - ) { state, tags -> + private val availableSortOrders = repository.sortOrders + private val capabilities = repository.filterCapabilities + private val filterOptions = SuspendLazy { repository.getFilterOptions() } + + val mangaSource: MangaSource + get() = repository.source + + val isFilterApplied: Boolean + get() = !currentListFilter.value.isEmpty() + + val sortOrder: StateFlow> = currentSortOrder.map { selected -> FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tags, - isLoading = tags.isLoading, - error = tags.error, + availableItems = availableSortOrders.sortedByOrdinal(), + selectedItem = selected, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) - override val filterTagsExcluded: StateFlow> = if (repository.isTagsExclusionSupported) { + val tags: StateFlow> = combine( + getTopTags(TAGS_LIMIT), + currentListFilter.distinctUntilChangedBy { it.tags }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.addFirstDistinct(selected.tags), + selectedItems = selected.tags, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val tagsExcluded: StateFlow> = if (capabilities.isTagsExclusionSupported) { combine( - currentState.distinctUntilChangedBy { it.tagsExclude }, - getBottomTagsAsFlow(4), - ) { state, tags -> - FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tagsExclude, - isLoading = tags.isLoading, - error = tags.error, + getBottomTags(TAGS_LIMIT), + currentListFilter.distinctUntilChangedBy { it.tagsExclude }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.addFirstDistinct(selected.tagsExclude), + selectedItems = selected.tagsExclude, + ) + }, + onFailure = { + FilterProperty.error(it) + }, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { - MutableStateFlow(emptyProperty()) + MutableStateFlow(FilterProperty.EMPTY) } - override val filterSortOrder: StateFlow> = - currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> - val orders = repository.sortOrders - FilterProperty( - availableItems = orders.mapTo(EnumSet.noneOf(GenericSortOrder::class.java)) { - GenericSortOrder.of(it) - }.sortedByOrdinal(), - selectedItems = setOf(GenericSortOrder.of(state.sortOrder)), - isLoading = false, - error = null, + val states: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.states }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableStates.sortedByOrdinal(), + selectedItems = selected.states, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val contentRating: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.contentRating }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableContentRating.sortedByOrdinal(), + selectedItems = selected.contentRating, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val contentTypes: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.types }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableContentTypes.sortedByOrdinal(), + selectedItems = selected.types, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val demographics: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.demographics }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableDemographics.sortedByOrdinal(), + selectedItems = selected.demographics, + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val locale: StateFlow> = combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.locale }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), + selectedItems = setOfNotNull(selected.locale), + ) + }, + onFailure = { + FilterProperty.error(it) + }, + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + + val originalLocale: StateFlow> = if (capabilities.isOriginalLocaleSupported) { + combine( + filterOptions.asFlow(), + currentListFilter.distinctUntilChangedBy { it.originalLocale }, + ) { available, selected -> + available.fold( + onSuccess = { + FilterProperty( + availableItems = it.availableLocales.sortedWithSafe(LocaleComparator()).addFirstDistinct(null), + selectedItems = setOfNotNull(selected.originalLocale), + ) + }, + onFailure = { + FilterProperty.error(it) + }, ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterSortDirection: StateFlow> = - currentState.distinctUntilChangedBy { it.sortOrder }.map { state -> - val orders = repository.sortOrders + val year: StateFlow> = if (capabilities.isYearSupported) { + currentListFilter.distinctUntilChangedBy { it.year }.map { selected -> FilterProperty( - availableItems = state.sortOrder.let { - val genericOrder = GenericSortOrder.of(it) - val result = EnumSet.noneOf(SortDirection::class.java) - if (genericOrder.ascending in orders) result.add(SortDirection.ASC) - if (genericOrder.descending in orders) result.add(SortDirection.DESC) - result - }?.sortedByOrdinal().orEmpty(), - selectedItems = setOf(state.sortOrder.direction), - isLoading = false, - error = null, + availableItems = listOf(YEAR_MIN, MAX_YEAR), + selectedItems = setOf(selected.year), ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterState: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.states }, - flowOf(repository.states), - ) { state, states -> - FilterProperty( - availableItems = states.sortedByOrdinal(), - selectedItems = state.states, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + val yearRange: StateFlow> = if (capabilities.isYearRangeSupported) { + currentListFilter.distinctUntilChanged { old, new -> + old.yearTo == new.yearTo && old.yearFrom == new.yearFrom + }.map { selected -> + FilterProperty( + availableItems = listOf(YEAR_MIN, MAX_YEAR), + selectedItems = setOf(selected.yearFrom, selected.yearTo), + ) + }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) + } else { + MutableStateFlow(FilterProperty.EMPTY) + } - override val filterContentRating: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.contentRating }, - flowOf(repository.contentRatings), - ) { rating, ratings -> - FilterProperty( - availableItems = ratings.sortedByOrdinal(), - selectedItems = rating.contentRating, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + fun reset() { + currentListFilter.value = MangaListFilter.EMPTY + } - override val filterLocale: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.locale }, - getLocalesAsFlow(), - ) { state, locales -> - val list = if (locales.items.isNotEmpty()) { - val l = ArrayList(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 = getHeaderFlow().stateIn( - scope = coroutineScope + Dispatchers.Default, - started = SharingStarted.Lazily, - initialValue = FilterHeaderModel( - chips = emptyList(), - sortOrder = repository.defaultSortOrder, - isFilterApplied = false, - ), + fun snapshot() = Snapshot( + sortOrder = currentSortOrder.value, + listFilter = currentListFilter.value, ) - override fun applyFilter(tags: Set) { - setTags(tags) + fun observe(): Flow = combine(currentSortOrder, currentListFilter, ::Snapshot) + + fun setSortOrder(newSortOrder: SortOrder) { + currentSortOrder.value = newSortOrder } - override fun setSortOrder(value: SortOrder) { - val available = repository.sortOrders - val sortOrder = if (value !in available) { - val generic = GenericSortOrder.of(value) - when { - generic.ascending in available -> generic.ascending - generic.descending in available -> generic.descending - else -> return - } - } else { - value - } - currentState.update { oldValue -> - oldValue.copy(sortOrder = sortOrder) - } - repository.defaultSortOrder = sortOrder + fun set(value: MangaListFilter) { + currentListFilter.value = value } - override fun setLanguage(value: Locale?) { - currentState.update { oldValue -> + fun setLocale(value: Locale?) { + currentListFilter.update { oldValue -> oldValue.copy(locale = value) } } - override fun setTag(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tags + value - } else { - oldValue.tags - value - } + fun setYear(value: Int) { + currentListFilter.update { oldValue -> + oldValue.copy(year = value) + } + } + + fun toggleState(value: MangaState, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + states = if (isSelected) oldValue.states + value else oldValue.states - value, + ) + } + } + + fun toggleContentRating(value: ContentRating, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value, + ) + } + } + + fun toggleTag(value: MangaTag, isSelected: Boolean) { + currentListFilter.update { oldValue -> + val newTags = if (capabilities.isMultipleTagsSupported) { + if (isSelected) oldValue.tags + value else oldValue.tags - value } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } + if (isSelected) setOf(value) else emptySet() } oldValue.copy( tags = newTags, @@ -275,266 +303,91 @@ class FilterCoordinator @Inject constructor( } } - override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tagsExclude + value - } else { - oldValue.tagsExclude - value - } + fun toggleTagExclude(value: MangaTag, isSelected: Boolean) { + currentListFilter.update { oldValue -> + val newTagsExclude = if (capabilities.isMultipleTagsSupported) { + if (isSelected) oldValue.tagsExclude + value else oldValue.tagsExclude - value } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } + if (isSelected) setOf(value) else emptySet() } oldValue.copy( - tagsExclude = newTags, - tags = oldValue.tags - newTags, + tags = oldValue.tags - newTagsExclude, + tagsExclude = newTagsExclude, ) } } - override fun setState(value: MangaState, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newStates = if (addOrRemove) { - oldValue.states + value - } else { - oldValue.states - value - } - oldValue.copy(states = newStates) + fun getAllTags(): Flow>> = filterOptions.asFlow().map { + it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) } + } + + private fun getTopTags(limit: Int): Flow>> = combine( + flow { emit(searchRepository.getTopTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(limit) + result.addAll(suggested.take(limit)) + if (result.size < limit) { + result.addAll(all.shuffled().take(limit - result.size)) } - } - - override fun setContentRating(value: ContentRating, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newRating = if (addOrRemove) { - oldValue.contentRating + value - } else { - oldValue.contentRating - value - } - oldValue.copy(contentRating = newRating) - } - } - - override fun onListHeaderClick(item: ListHeader, view: View) { - currentState.update { oldValue -> - oldValue.copy( - sortOrder = oldValue.sortOrder, - tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, - locale = if (item.payload == R.string.language) null else oldValue.locale, - states = if (item.payload == R.string.state) emptySet() else oldValue.states, - ) - } - } - - fun observeAvailableTags(): Flow?> = flow { - if (!availableTagsDeferred.isCompleted) { - emit(emptySet()) - } - emit(availableTagsDeferred.await().getOrNull()) - } - - fun observeState() = currentState.asStateFlow() - - fun setTags(tags: Set) { - currentState.update { oldValue -> - oldValue.copy( - tags = tags, - tagsExclude = oldValue.tagsExclude - tags, - ) - } - } - - fun reset() { - currentState.update { oldValue -> - MangaListFilter.Advanced.Builder(oldValue.sortOrder).build() - } - } - - fun snapshot() = currentState.value - - private fun getHeaderFlow() = combine( - observeState(), - observeAvailableTags(), - ) { state, available -> - val chips = createChipsList(state, available.orEmpty(), 8) - FilterHeaderModel( - chips = chips, - sortOrder = state.sortOrder, - isFilterApplied = !state.isEmpty(), - ) - } - - private fun getLocalesAsFlow(): Flow> = 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>, limit: Int): Flow> = combine( - selectedTags.map { - if (it.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) - } else { - searchRepository.getTagsSuggestion(it).take(limit) - } - }, - tagsFlow, - ) { 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 fun getBottomTagsAsFlow(limit: Int): Flow> = combine( - flow { emit(searchRepository.getRareTags(repository.source, limit)) }, - tagsFlow, - ) { 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( - filterState: MangaListFilter.Advanced, - availableTags: Set, - limit: Int, - ): List { - val selectedTags = filterState.tags.toMutableSet() - var tags = if (selectedTags.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) + if (result.isNotEmpty()) { + Result.success(result) } else { - searchRepository.getTagsSuggestion(selectedTags).take(limit) + options.map { result } } - if (tags.size < limit) { - tags = tags + availableTags.take(limit - tags.size) + } + + private fun getBottomTags(limit: Int): Flow>> = combine( + flow { emit(searchRepository.getRareTags(repository.source, limit)) }, + filterOptions.asFlow(), + ) { suggested, options -> + val all = options.getOrNull()?.availableTags.orEmpty() + val result = ArrayList(limit) + result.addAll(suggested.take(limit)) + if (result.size < limit) { + result.addAll(all.shuffled().take(limit - result.size)) } - if (tags.isEmpty() && selectedTags.isEmpty()) { - return emptyList() + if (result.isNotEmpty()) { + Result.success(result) + } else { + options.map { result } } - val result = LinkedList() - for (tag in tags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = selectedTags.remove(tag), - data = tag, - ) - if (model.isChecked) { - result.addFirst(model) - } else { - result.addLast(model) + } + + private fun List.addFirstDistinct(other: Collection): List { + val result = ArrayDeque(this.size + other.size) + result.addAll(this) + for (item in other) { + if (item !in result) { + result.addFirst(item) } } - for (tag in selectedTags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = true, - data = tag, - ) - result.addFirst(model) + return result + } + + private fun List.addFirstDistinct(item: T): List { + val result = ArrayDeque(this.size + 1) + result.addAll(this) + if (item !in result) { + result.addFirst(item) } return result } - private suspend fun tryLoadTags(): Result> { - val shouldRetryOnError = availableTagsDeferred.isCompleted - val result = availableTagsDeferred.await() - if (result.isFailure && shouldRetryOnError) { - availableTagsDeferred = loadTagsAsync() - return availableTagsDeferred.await() - } - return result - } - - private suspend fun tryLoadLocales(): Result> { - 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) { - runCatchingCancellable { - repository.getTags() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { - runCatchingCancellable { - repository.getLocales() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun mergeTags(primary: Set, secondary: Set): Set { - val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale)) - result.addAll(secondary) - result.addAll(primary) - return result - } - - private fun loadAllTags() { - 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() + e.toErrorFooter() - } - } - } - - private fun appendTagsList(newTags: Collection, isLoading: Boolean) = allTags.update { oldList -> - val oldTags = oldList.filterIsInstance() - buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) { - addAll(oldTags) - newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) } - val tempSet = HashSet(size) - removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) } - sortBy { (it as TagCatalogItem).tag.title } - if (isLoading) { - add(LoadingFooter()) - } - } - } - - private data class PendingData( - val items: Collection, - val isLoading: Boolean, - val error: Throwable?, + data class Snapshot( + val sortOrder: SortOrder, + val listFilter: MangaListFilter, ) - private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) + interface Owner { - private fun emptyProperty() = FilterProperty(emptyList(), emptySet(), false, null) + val filterCoordinator: FilterCoordinator + } - private class TagTitleComparator(lc: String?) : Comparator { + private companion object { - private val collator = lc?.let { Collator.getInstance(Locale(it)) } - - override fun compare(o1: MangaTag, o2: MangaTag): Int { - val t1 = o1.title.lowercase() - val t2 = o2.title.lowercase() - return collator?.compare(t1, t2) ?: compareValues(t1, t2) - } + const val TAGS_LIMIT = 12 + val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 1df723a18..e34b872ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -6,6 +6,9 @@ import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.isVisible import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView @@ -15,12 +18,17 @@ import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.MangaTag +import javax.inject.Inject import com.google.android.material.R as materialR +@AndroidEntryPoint class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener { - private val filter: MangaFilter - get() = (requireActivity() as FilterOwner).filter + @Inject + lateinit var filterHeaderProducer: FilterHeaderProducer + + private val filter: FilterCoordinator + get() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { return FragmentFilterHeaderBinding.inflate(inflater, container, false) @@ -29,7 +37,9 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.chipsTags.onChipClickListener = this - filter.header.observe(viewLifecycleOwner, ::onDataChanged) + filterHeaderProducer.observeHeader(filter) + .flowOn(Dispatchers.Default) + .observe(viewLifecycleOwner, ::onDataChanged) } override fun onWindowInsetsChanged(insets: Insets) = Unit @@ -39,7 +49,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV if (tag == null) { TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) } else { - filter.setTag(tag, !chip.isChecked) + filter.toggleTag(tag, !chip.isChecked) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt new file mode 100644 index 000000000..02ea5169f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.filter.ui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import java.util.LinkedList +import javax.inject.Inject + +class FilterHeaderProducer @Inject constructor( + private val searchRepository: MangaSearchRepository, +) { + + fun observeHeader(filterCoordinator: FilterCoordinator): Flow { + return filterCoordinator.tags.mapLatest { + createChipsList( + source = filterCoordinator.mangaSource, + property = it, + limit = 8, + ) + }.combine(filterCoordinator.observe()) { chipList, snapshot -> + FilterHeaderModel( + chips = chipList, + sortOrder = snapshot.sortOrder, + isFilterApplied = !snapshot.listFilter.isEmpty(), + ) + } + } + + private suspend fun createChipsList( + source: MangaSource, + property: FilterProperty, + limit: Int, + ): List { + val selectedTags = property.selectedItems.toMutableSet() + var tags = if (selectedTags.isEmpty()) { + searchRepository.getTagsSuggestion("", limit, source) + } else { + searchRepository.getTagsSuggestion(selectedTags).take(limit) + } + if (tags.size < limit) { + tags = tags + property.availableItems.take(limit - tags.size) + } + if (tags.isEmpty() && selectedTags.isEmpty()) { + return emptyList() + } + val result = LinkedList() + for (tag in tags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = selectedTags.remove(tag), + data = tag, + ) + if (model.isChecked) { + result.addFirst(model) + } else { + result.addLast(model) + } + } + for (tag in selectedTags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = true, + data = tag, + ) + result.addFirst(model) + } + return result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt deleted file mode 100644 index d75f81c4a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -interface FilterOwner { - - val filter: MangaFilter -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt deleted file mode 100644 index 11dfcef1a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.core.model.GenericSortOrder -import org.koitharu.kotatsu.core.model.SortDirection -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.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import java.util.Locale - -interface MangaFilter : OnFilterChangedListener { - - val allTags: StateFlow> - - val filterTags: StateFlow> - - val filterTagsExcluded: StateFlow> - - val filterSortOrder: StateFlow> - - val filterSortDirection: StateFlow> - - val filterState: StateFlow> - - val filterContentRating: StateFlow> - - val filterLocale: StateFlow> - - val header: StateFlow - - fun applyFilter(tags: Set) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt deleted file mode 100644 index 785f32ec6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.Locale - -interface OnFilterChangedListener : ListHeaderClickListener { - - fun setSortOrder(value: SortOrder) - - fun setLanguage(value: Locale?) - - fun setTag(value: MangaTag, addOrRemove: Boolean) - - fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) - - fun setState(value: MangaState, addOrRemove: Boolean) - - fun setContentRating(value: ContentRating, addOrRemove: Boolean) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt index a05157a3d..54777769a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt @@ -1,11 +1,53 @@ package org.koitharu.kotatsu.filter.ui.model -data class FilterProperty( +data class FilterProperty( val availableItems: List, val selectedItems: Set, val isLoading: Boolean, val error: Throwable?, ) { + constructor( + availableItems: List, + selectedItems: Set, + ) : this( + availableItems = availableItems, + selectedItems = selectedItems, + isLoading = false, + error = null, + ) + + constructor( + availableItems: List, + selectedItem: T, + ) : this( + availableItems = availableItems, + selectedItems = setOf(selectedItem), + isLoading = false, + error = null, + ) + fun isEmpty(): Boolean = availableItems.isEmpty() + + companion object { + + val LOADING = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + isLoading = true, + error = null, + ) + + val EMPTY = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + ) + + fun error(error: Throwable) = FilterProperty( + availableItems = emptyList(), + selectedItems = emptySet(), + isLoading = false, + error = error, + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index e963deb77..84dfd9034 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -13,31 +13,35 @@ import androidx.core.view.updatePadding import androidx.fragment.app.FragmentManager import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.chip.Chip +import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.GenericSortOrder import org.koitharu.kotatsu.core.model.SortDirection 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.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView +import org.koitharu.kotatsu.core.util.ext.setValueRounded 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.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import java.util.Locale import com.google.android.material.R as materialR class FilterSheetFragment : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, - ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener { + ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -52,13 +56,14 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } } val filter = requireFilter() - filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) - filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) - filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) - filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) - filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) - filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) - filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) + filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) + // filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) + filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) + filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) + filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) + filter.states.observe(viewLifecycleOwner, this::onStateChanged) + filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) + filter.year.observe(viewLifecycleOwner, this::onYearChanged) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this @@ -66,12 +71,13 @@ class FilterSheetFragment : BaseAdaptiveSheet(), binding.chipsContentRating.onChipClickListener = this binding.chipsGenres.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this + binding.sliderYear.addOnChangeListener(this) binding.layoutSortDirection.addOnButtonCheckedListener(this) } override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { if (isChecked) { - setSortDirection(getSortDirection(checkedId) ?: return) + // setSortDirection(getSortDirection(checkedId) ?: return) } } @@ -79,33 +85,43 @@ class FilterSheetFragment : BaseAdaptiveSheet(), val filter = requireFilter() when (parent.id) { R.id.spinner_order -> { - val genericOrder = filter.filterSortOrder.value.availableItems[position] - val direction = getSortDirection(requireViewBinding().layoutSortDirection.checkedButtonId) - filter.setSortOrder(genericOrder[direction ?: SortDirection.DESC]) + val value = filter.sortOrder.value.availableItems[position] + filter.setSortOrder(value) } - R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position]) + R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) } } override fun onNothingSelected(parent: AdapterView<*>?) = Unit + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (!fromUser) { + return + } + val intValue = value.toInt() + val filter = requireFilter() + when (slider.id) { + R.id.slider_year -> filter.setYear(intValue) + } + } + override fun onChipClick(chip: Chip, data: Any?) { val filter = requireFilter() when (data) { - is MangaState -> filter.setState(data, !chip.isChecked) + is MangaState -> filter.toggleState(data, !chip.isChecked) is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { - filter.setTagExcluded(data, !chip.isChecked) + filter.toggleTagExclude(data, !chip.isChecked) } else { - filter.setTag(data, !chip.isChecked) + filter.toggleTag(data, !chip.isChecked) } - is ContentRating -> filter.setContentRating(data, !chip.isChecked) + is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude) } } - private fun onSortOrderChanged(value: FilterProperty) { + private fun onSortOrderChanged(value: FilterProperty) { val b = viewBinding ?: return b.textViewOrderTitle.isGone = value.isEmpty() b.cardOrder.isGone = value.isEmpty() @@ -117,7 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.spinnerOrder.context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, - value.availableItems.map { b.spinnerOrder.context.getString(it.titleResId) }, + value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, ) val selectedIndex = value.availableItems.indexOf(selected) if (selectedIndex >= 0) { @@ -271,15 +287,20 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.chipsContentRating.setChips(chips) } - private fun requireFilter() = (requireActivity() as FilterOwner).filter - - private fun setSortDirection(direction: SortDirection) { - val filter = requireFilter() - val currentOrder = filter.filterSortOrder.value.selectedItems.singleOrNull() ?: return - val newOrder = currentOrder[direction] - filter.setSortOrder(newOrder) + private fun onYearChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewYear.isGone = value.isEmpty() + b.sliderYear.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + b.sliderYear.valueFrom = value.availableItems.first().toFloat() + b.sliderYear.valueTo = value.availableItems.last().toFloat() + b.sliderYear.setValueRounded((value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN).toFloat()) } + private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator + private fun getSortDirection(@IdRes buttonId: Int): SortDirection? = when (buttonId) { R.id.button_order_asc -> SortDirection.ASC R.id.button_order_desc -> SortDirection.DESC diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt new file mode 100644 index 000000000..6d827d2ff --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagTitleComparator.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.filter.ui.tags + +import org.koitharu.kotatsu.parsers.model.MangaTag +import java.text.Collator +import java.util.Locale + +class TagTitleComparator(lc: String?) : Comparator { + + private val collator = lc?.let { Collator.getInstance(Locale(it)) } + + override fun compare(o1: MangaTag, o2: MangaTag): Int { + val t1 = o1.title.lowercase() + val t2 = o2.title.lowercase() + return collator?.compare(t1, t2) ?: compareValues(t1, t2) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt index ea17faec5..24e1e529e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetTagsBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem @AndroidEntryPoint @@ -32,7 +32,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( - filter = (requireActivity() as FilterOwner).filter, + filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator, isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE), ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt index 092c6950c..5b83142b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt @@ -14,40 +14,43 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.parsers.model.MangaTag @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) class TagsCatalogViewModel @AssistedInject constructor( - @Assisted private val filter: MangaFilter, + @Assisted private val filter: FilterCoordinator, @Assisted private val isExcluded: Boolean, ) : BaseViewModel() { val searchQuery = MutableStateFlow("") private val filterProperty: StateFlow> - get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags + get() = if (isExcluded) filter.tagsExcluded else filter.tags - private val tags = combine( - filter.allTags, + private val tags: StateFlow> = combine( + filter.getAllTags(), filterProperty.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) + all.fold( + onSuccess = { + it.map { tag -> + TagCatalogItem( + tag = tag, + isChecked = tag in selected, + ) } - } else { - x - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value) + }, + onFailure = { + listOf(it.toErrorState(false)) + }, + ) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val content = combine(tags, searchQuery) { raw, query -> raw.filter { x -> @@ -57,15 +60,15 @@ class TagsCatalogViewModel @AssistedInject constructor( fun handleTagClick(tag: MangaTag, isChecked: Boolean) { if (isExcluded) { - filter.setTagExcluded(tag, !isChecked) + filter.toggleTagExclude(tag, !isChecked) } else { - filter.setTag(tag, !isChecked) + filter.toggleTag(tag, !isChecked) } } @AssistedFactory interface Factory { - fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel + fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt index e71253b0b..e266be08b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelExt.kt @@ -4,7 +4,7 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.util.ext.getDisplayIcon -import org.koitharu.kotatsu.core.util.ext.ifZero +import org.koitharu.kotatsu.parsers.util.ifZero fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction: Int = 0) = ErrorState( exception = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index c28db648e..4512e2488 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag @@ -105,11 +105,11 @@ class PreviewFragment : BaseFragment(), View.OnClickList override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return - val filter = (activity as? FilterOwner)?.filter + val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator if (filter == null) { startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) } else { - filter.setTag(tag, true) + filter.toggleTag(tag, true) closeSelf() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 7c055de1d..54f8f0030 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -25,18 +25,16 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -53,12 +51,15 @@ class LocalMangaRepository @Inject constructor( override val source = LocalMangaSource private val localMappingCache = LocalMangaMappingCache() - override val isMultipleTagsSupported: Boolean = true - override val isTagsExclusionSupported: Boolean = true - override val isSearchSupported: Boolean = true + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) - override val states = emptySet() - override val contentRatings = emptySet() override var defaultSortOrder: SortOrder get() = settings.localListOrder @@ -66,7 +67,9 @@ class LocalMangaRepository @Inject constructor( settings.localListOrder = value } - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + override suspend fun getFilterOptions() = MangaListFilterOptions() + + override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { if (offset > 0) { return emptyList() } @@ -74,30 +77,25 @@ class LocalMangaRepository @Inject constructor( if (settings.isNsfwContentDisabled) { list.removeIf { it.manga.isNsfw } } - when (filter) { - is MangaListFilter.Search -> { - list.retainAll { x -> x.isMatchesQuery(filter.query) } + if (filter != null) { + val query = filter.query + if (!query.isNullOrEmpty()) { + list.retainAll { x -> x.isMatchesQuery(query) } } - - is MangaListFilter.Advanced -> { - if (filter.tags.isNotEmpty()) { - list.retainAll { x -> x.containsTags(filter.tags) } - } - if (filter.tagsExclude.isNotEmpty()) { - list.removeAll { x -> x.containsAnyTag(filter.tags) } - } - when (filter.sortOrder) { - SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) - SortOrder.RATING -> list.sortByDescending { it.manga.rating } - SortOrder.NEWEST, - SortOrder.UPDATED, - -> list.sortByDescending { it.createdAt } - - else -> Unit - } + if (filter.tags.isNotEmpty()) { + list.retainAll { x -> x.containsTags(filter.tags) } } + if (filter.tagsExclude.isNotEmpty()) { + list.removeAll { x -> x.containsAnyTag(filter.tags) } + } + } + when (order) { + SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) + SortOrder.RATING -> list.sortByDescending { it.manga.rating } + SortOrder.NEWEST, + SortOrder.UPDATED -> list.sortByDescending { it.createdAt } - null -> Unit + else -> Unit } return list.unwrap() } @@ -173,10 +171,6 @@ class LocalMangaRepository @Inject constructor( override suspend fun getPageUrl(page: MangaPage) = page.url - override suspend fun getTags() = emptySet() - - override suspend fun getLocales() = emptySet() - override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga): File? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt index 506b3a657..d4b5ee251 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt @@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor( } suspend operator fun invoke(ids: Set) { - val list = localMangaRepository.getList(0, null) + val list = localMangaRepository.getList(0, null, null) var removed = 0 for (manga in list) { if (manga.id in ids) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt index c484f949c..877395a40 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt @@ -38,7 +38,7 @@ class DeleteReadChaptersUseCase @Inject constructor( } suspend operator fun invoke(): Int { - val list = localMangaRepository.getList(0, null) + val list = localMangaRepository.getList(0, null, null) if (list.isEmpty()) { return 0 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 8deaed651..44794afa4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -23,15 +23,14 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity -class LocalListFragment : MangaListFragment(), FilterOwner { +class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { private val permissionRequestLauncher = registerForActivityResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -56,8 +55,8 @@ class LocalListFragment : MangaListFragment(), FilterOwner { override val viewModel by viewModels() - override val filter: MangaFilter - get() = viewModel + override val filterCoordinator: FilterCoordinator + get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 7e566c679..ac8993e9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.filter.ui.FilterHeaderProducer import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel @@ -32,7 +33,7 @@ import javax.inject.Inject class LocalListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - filter: FilterCoordinator, + filterCoordinator: FilterCoordinator, private val settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, mangaListMapper: MangaListMapper, @@ -40,11 +41,12 @@ class LocalListViewModel @Inject constructor( exploreRepository: ExploreRepository, @LocalStorageChanges private val localStorageChanges: SharedFlow, private val localStorageManager: LocalStorageManager, + filterHeaderProducer: FilterHeaderProducer, sourcesRepository: MangaSourcesRepository, ) : RemoteListViewModel( savedStateHandle, mangaRepositoryFactory, - filter, + filterCoordinator, settings, mangaListMapper, downloadScheduler, @@ -58,7 +60,7 @@ class LocalListViewModel @Inject constructor( launchJob(Dispatchers.Default) { localStorageChanges .collect { - loadList(filter.snapshot(), append = false).join() + loadList(filterCoordinator.snapshot(), append = false).join() } } settings.subscribe(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 05bffd1bd..a0341f7ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -62,7 +62,6 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.assertNotNull import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader @@ -452,9 +451,9 @@ class ReaderViewModel @Inject constructor( @WorkerThread private fun notifyStateChanged() { - val state = getCurrentState().assertNotNull("state") ?: return - val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return - val m = mangaDetails.value.assertNotNull("manga") ?: return + val state = getCurrentState() ?: return + val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return + val m = mangaDetails.value ?: return val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val newState = ReaderUiState( mangaName = m.toManga().title, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index e724884b4..21a0d5639 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -16,9 +16,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.databinding.ItemPageBinding +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 745a57971..e2629e4a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -11,8 +11,8 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 626dc91f1..b8d7ff663 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -25,8 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner @@ -35,12 +34,12 @@ import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint -class RemoteListFragment : MangaListFragment(), FilterOwner { +class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override val viewModel by viewModels() - override val filter: MangaFilter - get() = viewModel + override val filterCoordinator: FilterCoordinator + get() = viewModel.filterCoordinator override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) @@ -49,7 +48,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner { viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { startActivity(DetailsActivity.newIntent(binding.root.context, it)) } - viewModel.header.distinctUntilChangedBy { it.isFilterApplied } + filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { activity?.invalidateMenu() @@ -130,7 +129,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner { super.onPrepareMenu(menu) menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value - menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied + menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied } override fun onQueryTextSubmit(query: String?): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 6c96b8ff7..30906a4c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -31,7 +31,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -52,13 +51,13 @@ private const val FILTER_MIN_INTERVAL = 250L open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - private val filter: FilterCoordinator, + override val filterCoordinator: FilterCoordinator, settings: AppSettings, mangaListMapper: MangaListMapper, downloadScheduler: DownloadWorker.Scheduler, private val exploreRepository: ExploreRepository, sourcesRepository: MangaSourcesRepository, -) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter { +) : MangaListViewModel(settings, downloadScheduler), FilterCoordinator.Owner { val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val isRandomLoading = MutableStateFlow(false) @@ -72,7 +71,7 @@ open class RemoteListViewModel @Inject constructor( private var randomJob: Job? = null val isSearchAvailable: Boolean - get() = repository.isSearchSupported + get() = repository.filterCapabilities.isSearchSupported val browserUrl: String? get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" } @@ -93,7 +92,7 @@ open class RemoteListViewModel @Inject constructor( ) list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied)) + list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { mangaListMapper.toListModelList(this, list, mode) when { @@ -107,7 +106,7 @@ open class RemoteListViewModel @Inject constructor( }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) init { - filter.observeState() + filterCoordinator.observe() .debounce(FILTER_MIN_INTERVAL) .onEach { filterState -> loadingJob?.cancelAndJoin() @@ -123,26 +122,26 @@ open class RemoteListViewModel @Inject constructor( } override fun onRefresh() { - loadList(filter.snapshot(), append = false) + loadList(filterCoordinator.snapshot(), append = false) } override fun onRetry() { - loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) + loadList(filterCoordinator.snapshot(), append = !mangaList.value.isNullOrEmpty()) } fun loadNextPage() { if (hasNextPage.value && listError.value == null) { - loadList(filter.snapshot(), append = true) + loadList(filterCoordinator.snapshot(), append = true) } } - fun resetFilter() = filter.reset() + fun resetFilter() = filterCoordinator.reset() override fun onUpdateFilter(tags: Set) { - applyFilter(tags) + filterCoordinator.set(MangaListFilter(tags = tags)) } - protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job { + protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job { loadingJob?.let { if (it.isActive) return it } @@ -151,7 +150,8 @@ open class RemoteListViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value.sizeOrZero() else 0, - filter = filterState, + order = filterState.sortOrder, + filter = filterState.listFilter, ) val prevList = mangaList.value.orEmpty() if (!append) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index cf21cdf49..217cee55a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -28,6 +28,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index e3df45981..924334415 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -125,6 +125,10 @@ class MangaSearchRepository @Inject constructor( return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() } + suspend fun getTopTags(source: MangaSource, limit: Int): List { + return db.getTagsDao().findPopularTags(source.name, limit).toMangaTagsList() + } + suspend fun getSourcesSuggestion(limit: Int): List = sourcesRepository.getTopSources(limit) fun getSourcesSuggestion(query: String, limit: Int): List { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 3819bb9ce..d7bd95c92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -36,14 +36,14 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ActivityMangaListBinding +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -53,15 +53,15 @@ import com.google.android.material.R as materialR @AndroidEntryPoint class MangaListActivity : BaseActivity(), - AppBarOwner, View.OnClickListener, FilterOwner, AppBarLayout.OnOffsetChangedListener { + AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener { override val appBar: AppBarLayout get() = viewBinding.appbar - override val filter: MangaFilter + override val filterCoordinator: FilterCoordinator get() = checkNotNull(findFilterOwner()) { - "Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}" - }.filter + "Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}" + }.filterCoordinator private var source: MangaSource? = null @@ -122,7 +122,7 @@ class MangaListActivity : private fun initList(source: MangaSource, tags: Set?) { val fm = supportFragmentManager val existingFragment = fm.findFragmentById(R.id.container) - if (existingFragment is FilterOwner) { + if (existingFragment is FilterCoordinator.Owner) { initFilter(existingFragment) } else { fm.commit { @@ -141,7 +141,7 @@ class MangaListActivity : } } - private fun initFilter(filterOwner: FilterOwner) { + private fun initFilter(filterOwner: FilterCoordinator.Owner) { if (viewBinding.containerSide != null) { if (supportFragmentManager.findFragmentById(R.id.container_side) == null) { setSideFragment(FilterSheetFragment::class.java, null) @@ -154,18 +154,18 @@ class MangaListActivity : } } } - val filter = filterOwner.filter + val filter = filterOwner.filterCoordinator val chipSort = viewBinding.buttonOrder if (chipSort != null) { val filterBadge = ViewBadge(chipSort, this) filterBadge.setMaxCharacterCount(0) - filter.header.observe(this) { - chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) - filterBadge.counter = if (it.isFilterApplied) 1 else 0 + filter.observe().observe(this) { snapshot -> + chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) + filterBadge.counter = if (snapshot.listFilter.isEmpty()) 0 else 1 } } else { - filter.header.map { - it.textSummary + filter.observe().map { + it.listFilter.tags.joinToString { tag -> tag.title } }.flowOn(Dispatchers.Default) .observe(this) { supportActionBar?.subtitle = it @@ -173,8 +173,8 @@ class MangaListActivity : } } - private fun findFilterOwner(): FilterOwner? { - return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner + private fun findFilterOwner(): FilterCoordinator.Owner? { + return supportFragmentManager.findFragmentById(R.id.container) as? FilterCoordinator.Owner } private fun setSideFragment(cls: Class, args: Bundle?) = if (viewBinding.containerSide != null) { @@ -188,12 +188,12 @@ class MangaListActivity : } private class ApplyFilterRunnable( - private val filterOwner: FilterOwner, + private val filterOwner: FilterCoordinator.Owner, private val tags: Set, ) : Runnable { override fun run() { - filterOwner.filter.applyFilter(tags) + filterOwner.filterCoordinator.set(MangaListFilter(tags = tags)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 0bd53ef68..e1ceb0db2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -42,7 +42,7 @@ class SearchViewModel @Inject constructor( ) : MangaListViewModel(settings, downloadScheduler) { private val query = savedStateHandle.require(SearchFragment.ARG_QUERY) - private val repository = repositoryFactory.create(MangaSource(savedStateHandle.get(SearchFragment.ARG_SOURCE))) + private val repository = repositoryFactory.create(MangaSource(savedStateHandle[SearchFragment.ARG_SOURCE])) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) @@ -105,7 +105,8 @@ class SearchViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value.sizeOrZero() else 0, - filter = MangaListFilter.Search(query), + order = null, + filter = MangaListFilter(query = query), ) val prevList = mangaList.value.orEmpty() if (!append) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 686271449..155c9eefd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -116,14 +116,14 @@ class MultiSearchViewModel @Inject constructor( val semaphore = Semaphore(MAX_PARALLELISM) sources.mapNotNull { source -> val repository = mangaRepositoryFactory.create(source) - if (!repository.isSearchSupported) { + if (!repository.filterCapabilities.isSearchSupported) { null } else { launch { val item = runCatchingCancellable { semaphore.withPermit { mangaListMapper.toListModelList( - manga = repository.getList(offset = 0, filter = MangaListFilter.Search(q)), + manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), mode = ListMode.GRID, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 51f5f84b1..172db74e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -250,15 +250,14 @@ class SuggestionsWorker @AssistedInject constructor( val repository = mangaRepositoryFactory.create(source) val availableOrders = repository.sortOrders val order = preferredSortOrders.first { it in availableOrders } - val availableTags = repository.getTags() + val availableTags = repository.getFilterOptions().availableTags val tag = tags.firstNotNullOfOrNull { title -> availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } } val list = repository.getList( offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), + order = order, + filter = MangaListFilter(tags = setOfNotNull(tag)) ).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 27f1494ac..1c9860652 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -49,7 +49,7 @@ @@ -62,9 +62,11 @@ android:layout_marginTop="12dp" android:baselineAligned="false" android:orientation="horizontal" + android:visibility="gone" android:weightSum="2" app:selectionRequired="true" - app:singleSelection="true"> + app:singleSelection="true" + tools:visibility="visible"> + + + + + + + + + @@ -211,6 +244,28 @@ app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" tools:visibility="visible" /> + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 7c8ba9833..511e2291c 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -35,6 +35,7 @@ 8dp 24dp 92dp + 56dp 142dp 6dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 101eef40e..962b8d8f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -707,4 +707,16 @@ No fix required for \"%s\" No alternatives found for \"%s\" This function will find alternative sources for the selected manga. The task will take some time and will proceed in the background + Novel + Manhua + Manhwa + Recently added + Added long ago + Popular this hour + Popular today + Popular this week + Popular this month + Popular this year + Original language + Year From 26a33e5d9d3256db671c0b2599bcc0bf193fc277 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 21 Sep 2024 09:11:58 +0300 Subject: [PATCH 13/71] Add new filter fields --- .../org/koitharu/kotatsu/core/model/Manga.kt | 11 ++ .../koitharu/kotatsu/core/util/ext/View.kt | 12 ++ .../kotatsu/filter/ui/FilterCoordinator.kt | 23 ++++ .../filter/ui/sheet/FilterSheetFragment.kt | 102 +++++++++++++++- app/src/main/res/layout/sheet_filter.xml | 112 +++++++++++++++++- app/src/main/res/values/strings.xml | 5 + 6 files changed, 256 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 4114c6f58..aa5bd3330 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaState @@ -68,6 +69,16 @@ val ContentRating.titleResId: Int ContentRating.ADULT -> R.string.rating_adult } +@get:StringRes +val Demographic.titleResId: Int + get() = when (this) { + Demographic.SHOUNEN -> R.string.demographic_shounen + Demographic.SHOUJO -> R.string.demographic_shoujo + Demographic.SEINEN -> R.string.demographic_seinen + Demographic.JOSEI -> R.string.demographic_josei + Demographic.NONE -> R.string.none + } + fun Manga.findChapter(id: Long): MangaChapter? { return chapters?.findById(id) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index 3a6b9f1ca..f995e098e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -18,6 +18,7 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip import com.google.android.material.progressindicator.BaseProgressIndicator +import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import com.google.android.material.tabs.TabLayout import kotlin.math.roundToInt @@ -88,6 +89,17 @@ fun Slider.setValueRounded(newValue: Float) { value = roundedValue.coerceIn(valueFrom, valueTo) } +fun RangeSlider.setValuesRounded(vararg newValues: Float) { + val step = stepSize + values = newValues.map { newValue -> + if (step <= 0f) { + newValue + } else { + (newValue / step).roundToInt() * step + } + } +} + fun RecyclerView.invalidateNestedItemDecorations() { descendants.filterIsInstance().forEach { it.invalidateItemDecorations() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index c2680c6de..9aa8de63a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -67,6 +67,9 @@ class FilterCoordinator @Inject constructor( val isFilterApplied: Boolean get() = !currentListFilter.value.isEmpty() + val query: StateFlow = currentListFilter.map { it.query } + .stateIn(coroutineScope, SharingStarted.Lazily, null) + val sortOrder: StateFlow> = currentSortOrder.map { selected -> FilterProperty( availableItems = availableSortOrders.sortedByOrdinal(), @@ -261,6 +264,12 @@ class FilterCoordinator @Inject constructor( currentListFilter.value = value } + fun setQuery(value: String?) { + currentListFilter.update { oldValue -> + oldValue.copy(query = value?.trim()?.takeUnless { it.isEmpty() }) + } + } + fun setLocale(value: Locale?) { currentListFilter.update { oldValue -> oldValue.copy(locale = value) @@ -273,6 +282,12 @@ class FilterCoordinator @Inject constructor( } } + fun setYearRange(valueFrom: Int, valueTo: Int) { + currentListFilter.update { oldValue -> + oldValue.copy(yearFrom = valueFrom, yearTo = valueTo) + } + } + fun toggleState(value: MangaState, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( @@ -289,6 +304,14 @@ class FilterCoordinator @Inject constructor( } } + fun toggleContentType(value: ContentType, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + types = if (isSelected) oldValue.types + value else oldValue.types - value, + ) + } + } + fun toggleTag(value: MangaTag, isSelected: Boolean) { currentListFilter.update { oldValue -> val newTags = if (capabilities.isMultipleTagsSupported) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index 84dfd9034..282361ddb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -13,6 +13,8 @@ import androidx.core.view.updatePadding import androidx.fragment.app.FragmentManager import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.chip.Chip +import com.google.android.material.slider.BaseOnChangeListener +import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.SortDirection @@ -25,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.setValueRounded +import org.koitharu.kotatsu.core.util.ext.setValuesRounded import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.SheetFilterBinding @@ -32,16 +35,19 @@ import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN +import org.koitharu.kotatsu.parsers.util.toIntUp import java.util.Locale import com.google.android.material.R as materialR class FilterSheetFragment : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, - ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener { + ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -62,16 +68,21 @@ class FilterSheetFragment : BaseAdaptiveSheet(), filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged) + filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged) filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) + filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged) filter.year.observe(viewLifecycleOwner, this::onYearChanged) + filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this binding.chipsState.onChipClickListener = this + binding.chipsTypes.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this binding.chipsGenres.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this - binding.sliderYear.addOnChangeListener(this) + binding.sliderYear.addOnChangeListener(this::onSliderValueChange) + binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.layoutSortDirection.addOnButtonCheckedListener(this) } @@ -95,14 +106,33 @@ class FilterSheetFragment : BaseAdaptiveSheet(), override fun onNothingSelected(parent: AdapterView<*>?) = Unit - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + private fun onSliderValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (!fromUser) { return } val intValue = value.toInt() val filter = requireFilter() when (slider.id) { - R.id.slider_year -> filter.setYear(intValue) + R.id.slider_year -> filter.setYear( + if (intValue <= slider.valueFrom.toIntUp()) { + YEAR_UNKNOWN + } else { + intValue + }, + ) + } + } + + private fun onRangeSliderValueChange(slider: RangeSlider, value: Float, fromUser: Boolean) { + if (!fromUser) { + return + } + val filter = requireFilter() + when (slider.id) { + R.id.slider_yearsRange -> filter.setYearRange( + valueFrom = slider.valueFrom.toInt(), + valueTo = slider.valueTo.toInt(), + ) } } @@ -116,6 +146,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), filter.toggleTag(data, !chip.isChecked) } + is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude) } @@ -270,6 +301,23 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.chipsState.setChips(chips) } + private fun onContentTypesChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewTypesTitle.isGone = value.isEmpty() + b.chipsTypes.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val chips = value.availableItems.map { type -> + ChipsView.ChipModel( + title = getString(type.titleResId), + isChecked = type in value.selectedItems, + data = type, + ) + } + b.chipsTypes.setChips(chips) + } + private fun onContentRatingChanged(value: FilterProperty) { val b = viewBinding ?: return b.textViewContentRatingTitle.isGone = value.isEmpty() @@ -287,16 +335,58 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.chipsContentRating.setChips(chips) } + private fun onDemographicsChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewDemographicsTitle.isGone = value.isEmpty() + b.chipsDemographics.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val chips = value.availableItems.map { demographic -> + ChipsView.ChipModel( + title = getString(demographic.titleResId), + isChecked = demographic in value.selectedItems, + data = demographic, + ) + } + b.chipsDemographics.setChips(chips) + } + private fun onYearChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewYear.isGone = value.isEmpty() + b.headerYear.isGone = value.isEmpty() b.sliderYear.isGone = value.isEmpty() if (value.isEmpty()) { return } + val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN + b.textViewYearValue.text = if (currentValue == YEAR_UNKNOWN) { + getString(R.string.none) + } else { + currentValue.toString() + } b.sliderYear.valueFrom = value.availableItems.first().toFloat() b.sliderYear.valueTo = value.availableItems.last().toFloat() - b.sliderYear.setValueRounded((value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN).toFloat()) + b.sliderYear.setValueRounded(currentValue.toFloat()) + } + + private fun onYearRangeChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.headerYearsRange.isGone = value.isEmpty() + b.sliderYearsRange.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + b.sliderYearsRange.valueFrom = value.availableItems.first().toFloat() + b.sliderYearsRange.valueTo = value.availableItems.last().toFloat() + val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom + val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo + b.textViewYearsRangeValue.text = getString( + R.string.memory_usage_pattern, + currentValueFrom.toString(), + currentValueTo.toString(), + ) + b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) } private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 1c9860652..2ad6ab344 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -204,6 +204,26 @@ app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" tools:visibility="visible" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 962b8d8f9..b219423ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -719,4 +719,9 @@ Popular this year Original language Year + Demographics + Shounen + Shoujo + Seinen + Josei From ea5ce2333535feecce84c5bfce5800f7c306ec0e Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 21 Sep 2024 11:58:45 +0300 Subject: [PATCH 14/71] Improve filter --- app/build.gradle | 6 +- .../koitharu/kotatsu/core/util/ext/View.kt | 2 +- .../kotatsu/filter/ui/FilterCoordinator.kt | 10 +- .../kotatsu/filter/ui/FilterFieldLayout.kt | 104 ++++ .../kotatsu/filter/ui/model/FilterProperty.kt | 2 + .../filter/ui/sheet/FilterSheetFragment.kt | 183 +++---- app/src/main/res/layout/sheet_filter.xml | 501 +++++++----------- app/src/main/res/layout/view_filter_field.xml | 44 ++ app/src/main/res/values/attrs.xml | 5 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 424 insertions(+), 435 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt create mode 100644 app/src/main/res/layout/view_filter_field.xml diff --git a/app/build.gradle b/app/build.gradle index b23267ead..e8093e261 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 669 - versionName = '7.6-a1' + versionCode = 670 + versionName = '7.6-a2' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:336c4a4d49') { + implementation('com.github.KotatsuApp:kotatsu-parsers:f2354957e6') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index f995e098e..82091bee9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -96,7 +96,7 @@ fun RangeSlider.setValuesRounded(vararg newValues: Float) { newValue } else { (newValue / step).roundToInt() * step - } + }.coerceIn(valueFrom, valueTo) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 9aa8de63a..059546880 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -36,6 +36,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.YEAR_MIN import org.koitharu.kotatsu.parsers.util.SuspendLazy +import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.search.domain.MangaSearchRepository import java.util.Calendar @@ -238,7 +239,7 @@ class FilterCoordinator @Inject constructor( }.map { selected -> FilterProperty( availableItems = listOf(YEAR_MIN, MAX_YEAR), - selectedItems = setOf(selected.yearFrom, selected.yearTo), + selectedItems = setOf(selected.yearFrom.ifZero { YEAR_MIN }, selected.yearTo.ifZero { MAX_YEAR }), ) }.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING) } else { @@ -258,6 +259,7 @@ class FilterCoordinator @Inject constructor( fun setSortOrder(newSortOrder: SortOrder) { currentSortOrder.value = newSortOrder + repository.defaultSortOrder = newSortOrder } fun set(value: MangaListFilter) { @@ -276,6 +278,12 @@ class FilterCoordinator @Inject constructor( } } + fun setOriginalLocale(value: Locale?) { + currentListFilter.update { oldValue -> + oldValue.copy(originalLocale = value) + } + } + fun setYear(value: Int) { currentListFilter.update { oldValue -> oldValue.copy(year = value) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt new file mode 100644 index 000000000..107f66072 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt @@ -0,0 +1,104 @@ +package org.koitharu.kotatsu.filter.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.setPadding +import androidx.core.widget.TextViewCompat +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList +import org.koitharu.kotatsu.core.util.ext.setThemeTextAppearance +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.databinding.ViewFilterFieldBinding +import java.util.LinkedList +import com.google.android.material.R as materialR + +class FilterFieldLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, +) : RelativeLayout(context, attrs) { + + private val contentViews = LinkedList() + private val binding = ViewFilterFieldBinding.inflate(LayoutInflater.from(context), this) + private var errorView: TextView? = null + private var isInitialized = true + + init { + context.withStyledAttributes(attrs, R.styleable.FilterFieldLayout, defStyleAttr) { + binding.textViewTitle.text = getString(R.styleable.FilterFieldLayout_title) + binding.buttonMore.isInvisible = !getBoolean(R.styleable.FilterFieldLayout_showMoreButton, false) + } + } + + override fun onViewAdded(child: View) { + super.onViewAdded(child) + if (!isInitialized) { + return + } + assert(child.id != View.NO_ID) + val lp = (child.layoutParams as? LayoutParams) ?: (generateDefaultLayoutParams() as LayoutParams) + lp.alignWithParent = true + lp.width = 0 + lp.addRule(ALIGN_PARENT_START) + lp.addRule(ALIGN_PARENT_END) + lp.addRule(BELOW, contentViews.lastOrNull()?.id ?: binding.textViewTitle.id) + child.layoutParams = lp + contentViews.add(child) + } + + override fun onViewRemoved(child: View?) { + super.onViewRemoved(child) + contentViews.remove(child) + } + + fun setValueText(valueText: String?) { + if (!binding.buttonMore.isVisible) { + binding.textViewValue.textAndVisible = valueText + } + } + + fun setError(errorMessage: String?) { + if (errorMessage == null && errorView == null) { + return + } + getErrorLabel().textAndVisible = errorMessage + } + + fun setOnMoreButtonClickListener(clickListener: OnClickListener?) { + binding.buttonMore.setOnClickListener(clickListener) + } + + private fun getErrorLabel(): TextView { + errorView?.let { + return it + } + val label = TextView(context) + label.id = R.id.textView_error + label.compoundDrawablePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) + label.gravity = Gravity.CENTER_VERTICAL or Gravity.START + label.setPadding(resources.getDimensionPixelOffset(R.dimen.margin_small)) + label.setThemeTextAppearance( + materialR.attr.textAppearanceBodySmall, + materialR.style.TextAppearance_Material3_BodySmall, + ) + label.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_error_small) + TextViewCompat.setCompoundDrawableTintList( + label, + context.getThemeColorStateList(materialR.attr.colorControlNormal), + ) + addView(errorView) + errorView = label + return label + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt index 54777769a..228911838 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt @@ -29,6 +29,8 @@ data class FilterProperty( fun isEmpty(): Boolean = availableItems.isEmpty() + fun isEmptyAndSuccess(): Boolean = availableItems.isEmpty() && error == null + companion object { val LOADING = FilterProperty( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index 282361ddb..dc9115332 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -7,17 +7,13 @@ import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter -import androidx.annotation.IdRes import androidx.core.view.isGone import androidx.core.view.updatePadding import androidx.fragment.app.FragmentManager -import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.chip.Chip -import com.google.android.material.slider.BaseOnChangeListener import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.SortDirection import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet @@ -29,7 +25,6 @@ import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded 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.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty @@ -43,11 +38,10 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN import org.koitharu.kotatsu.parsers.util.toIntUp import java.util.Locale -import com.google.android.material.R as materialR class FilterSheetFragment : BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, - ChipsView.OnChipClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener { + ChipsView.OnChipClickListener { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -63,8 +57,8 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } val filter = requireFilter() filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) - // filter.filterSortDirection.observe(viewLifecycleOwner, this::onSortDirectionChanged) filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged) + filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged) filter.tags.observe(viewLifecycleOwner, this::onTagsChanged) filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) filter.states.observe(viewLifecycleOwner, this::onStateChanged) @@ -75,6 +69,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) binding.spinnerLocale.onItemSelectedListener = this + binding.spinnerOriginalLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this binding.chipsState.onChipClickListener = this binding.chipsTypes.onChipClickListener = this @@ -83,24 +78,20 @@ class FilterSheetFragment : BaseAdaptiveSheet(), binding.chipsGenresExclude.onChipClickListener = this binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) - binding.layoutSortDirection.addOnButtonCheckedListener(this) - } - - override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { - if (isChecked) { - // setSortDirection(getSortDirection(checkedId) ?: return) + binding.layoutGenres.setOnMoreButtonClickListener { + TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = false) + } + binding.layoutGenresExclude.setOnMoreButtonClickListener { + TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = true) } } override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { val filter = requireFilter() when (parent.id) { - R.id.spinner_order -> { - val value = filter.sortOrder.value.availableItems[position] - filter.setSortOrder(value) - } - + R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position]) R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position]) + R.id.spinner_original_locale -> filter.setOriginalLocale(filter.originalLocale.value.availableItems[position]) } } @@ -130,8 +121,12 @@ class FilterSheetFragment : BaseAdaptiveSheet(), val filter = requireFilter() when (slider.id) { R.id.slider_yearsRange -> filter.setYearRange( - valueFrom = slider.valueFrom.toInt(), - valueTo = slider.valueTo.toInt(), + valueFrom = slider.values.firstOrNull()?.let { + if (it <= slider.valueFrom) YEAR_UNKNOWN else it.toInt() + } ?: YEAR_UNKNOWN, + valueTo = slider.values.lastOrNull()?.let { + if (it >= slider.valueTo) YEAR_UNKNOWN else it.toInt() + } ?: YEAR_UNKNOWN, ) } } @@ -154,8 +149,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onSortOrderChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewOrderTitle.isGone = value.isEmpty() - b.cardOrder.isGone = value.isEmpty() + b.layoutOrder.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -172,29 +166,9 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } } - private fun onSortDirectionChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.layoutSortDirection.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val selected = value.selectedItems.single() - b.buttonOrderAsc.isEnabled = SortDirection.ASC in value.availableItems - b.buttonOrderDesc.isEnabled = SortDirection.DESC in value.availableItems - b.layoutSortDirection.removeOnButtonCheckedListener(this) - b.layoutSortDirection.check( - when (selected) { - SortDirection.ASC -> R.id.button_order_asc - SortDirection.DESC -> R.id.button_order_desc - }, - ) - b.layoutSortDirection.addOnButtonCheckedListener(this) - } - private fun onLocaleChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewLocaleTitle.isGone = value.isEmpty() - b.cardLocale.isGone = value.isEmpty() + b.layoutLocale.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -211,83 +185,61 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } } - private fun onTagsChanged(value: FilterProperty) { + private fun onOriginalLocaleChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewGenresTitle.isGone = value.isEmpty() - b.chipsGenres.isGone = value.isEmpty() - b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources) + b.layoutOriginalLocale.isGone = value.isEmpty() if (value.isEmpty()) { return } - val chips = ArrayList(value.selectedItems.size + value.availableItems.size + 1) - value.selectedItems.mapTo(chips) { tag -> + val selected = value.selectedItems.singleOrNull() + b.spinnerOriginalLocale.adapter = ArrayAdapter( + b.spinnerOriginalLocale.context, + android.R.layout.simple_spinner_dropdown_item, + android.R.id.text1, + value.availableItems.map { it.getDisplayName(b.spinnerOriginalLocale.context) }, + ) + val selectedIndex = value.availableItems.indexOf(selected) + if (selectedIndex >= 0) { + b.spinnerOriginalLocale.setSelection(selectedIndex, false) + } + } + + private fun onTagsChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.layoutGenres.isGone = value.isEmptyAndSuccess() + b.layoutGenres.setError(value.error?.getDisplayMessage(resources)) + if (value.isEmpty()) { + return + } + val chips = value.availableItems.map { tag -> ChipsView.ChipModel( title = tag.title, - isChecked = true, + isChecked = tag in value.selectedItems, data = tag, ) } - value.availableItems.mapNotNullTo(chips) { tag -> - if (tag !in value.selectedItems) { - ChipsView.ChipModel( - title = tag.title, - isChecked = false, - data = tag, - ) - } else { - null - } - } - chips.add( - ChipsView.ChipModel( - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - ), - ) b.chipsGenres.setChips(chips) } private fun onTagsExcludedChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewGenresExcludeTitle.isGone = value.isEmpty() - b.chipsGenresExclude.isGone = value.isEmpty() + b.layoutGenresExclude.isGone = value.isEmpty() if (value.isEmpty()) { return } - val chips = ArrayList(value.selectedItems.size + value.availableItems.size + 1) - value.selectedItems.mapTo(chips) { tag -> + val chips = value.availableItems.map { tag -> ChipsView.ChipModel( - tint = 0, title = tag.title, - icon = 0, - isChecked = true, + isChecked = tag in value.selectedItems, data = tag, ) } - value.availableItems.mapNotNullTo(chips) { tag -> - if (tag !in value.selectedItems) { - ChipsView.ChipModel( - title = tag.title, - isChecked = false, - data = tag, - ) - } else { - null - } - } - chips.add( - ChipsView.ChipModel( - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - ), - ) b.chipsGenresExclude.setChips(chips) } private fun onStateChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewStateTitle.isGone = value.isEmpty() - b.chipsState.isGone = value.isEmpty() + b.layoutState.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -303,8 +255,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onContentTypesChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewTypesTitle.isGone = value.isEmpty() - b.chipsTypes.isGone = value.isEmpty() + b.layoutTypes.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -320,8 +271,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onContentRatingChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewContentRatingTitle.isGone = value.isEmpty() - b.chipsContentRating.isGone = value.isEmpty() + b.layoutContentRating.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -337,8 +287,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onDemographicsChanged(value: FilterProperty) { val b = viewBinding ?: return - b.textViewDemographicsTitle.isGone = value.isEmpty() - b.chipsDemographics.isGone = value.isEmpty() + b.layoutDemographics.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -354,17 +303,18 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onYearChanged(value: FilterProperty) { val b = viewBinding ?: return - b.headerYear.isGone = value.isEmpty() - b.sliderYear.isGone = value.isEmpty() + b.layoutYear.isGone = value.isEmpty() if (value.isEmpty()) { return } val currentValue = value.selectedItems.singleOrNull() ?: YEAR_UNKNOWN - b.textViewYearValue.text = if (currentValue == YEAR_UNKNOWN) { - getString(R.string.none) - } else { - currentValue.toString() - } + b.layoutYear.setValueText( + if (currentValue == YEAR_UNKNOWN) { + getString(R.string.any) + } else { + currentValue.toString() + }, + ) b.sliderYear.valueFrom = value.availableItems.first().toFloat() b.sliderYear.valueTo = value.availableItems.last().toFloat() b.sliderYear.setValueRounded(currentValue.toFloat()) @@ -372,8 +322,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), private fun onYearRangeChanged(value: FilterProperty) { val b = viewBinding ?: return - b.headerYearsRange.isGone = value.isEmpty() - b.sliderYearsRange.isGone = value.isEmpty() + b.layoutYearsRange.isGone = value.isEmpty() if (value.isEmpty()) { return } @@ -381,22 +330,18 @@ class FilterSheetFragment : BaseAdaptiveSheet(), b.sliderYearsRange.valueTo = value.availableItems.last().toFloat() val currentValueFrom = value.selectedItems.firstOrNull()?.toFloat() ?: b.sliderYearsRange.valueFrom val currentValueTo = value.selectedItems.lastOrNull()?.toFloat() ?: b.sliderYearsRange.valueTo - b.textViewYearsRangeValue.text = getString( - R.string.memory_usage_pattern, - currentValueFrom.toString(), - currentValueTo.toString(), + b.layoutYearsRange.setValueText( + getString( + R.string.memory_usage_pattern, + currentValueFrom.toInt().toString(), + currentValueTo.toInt().toString(), + ), ) b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo) } private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator - private fun getSortDirection(@IdRes buttonId: Int): SortDirection? = when (buttonId) { - R.id.button_order_asc -> SortDirection.ASC - R.id.button_order_desc -> SortDirection.DESC - else -> null - } - companion object { private const val TAG = "FilterSheet" diff --git a/app/src/main/res/layout/sheet_filter.xml b/app/src/main/res/layout/sheet_filter.xml index 2ad6ab344..eea7caa4d 100644 --- a/app/src/main/res/layout/sheet_filter.xml +++ b/app/src/main/res/layout/sheet_filter.xml @@ -17,360 +17,239 @@ android:id="@+id/scrollView" android:layout_width="match_parent" android:layout_height="match_parent" - android:scrollIndicators="top"> + android:scrollIndicators="top" + tools:ignore="UnusedAttribute"> - + app:title="@string/sort_order"> - - - - - - - - - + android:layout_marginHorizontal="@dimen/margin_small" + android:layout_marginTop="@dimen/margin_small"> - + - + - + + + android:layout_marginTop="@dimen/margin_small" + app:title="@string/language"> - - - + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/margin_small" + android:layout_marginTop="@dimen/margin_small"> - + + - + + + android:layout_marginTop="@dimen/margin_small" + app:title="@string/original_language"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginHorizontal="@dimen/margin_small" + android:layout_marginTop="@dimen/margin_small"> - + - + - + + + android:layout_marginTop="@dimen/margin_small" + app:showMoreButton="true" + app:title="@string/genres"> + - + + + android:layout_marginTop="@dimen/margin_small" + app:showMoreButton="true" + app:title="@string/genres_exclude"> - + android:layout_marginHorizontal="@dimen/margin_small" + android:layout_marginTop="@dimen/margin_small" + app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" /> - + - - - + android:layout_marginTop="@dimen/margin_small" + app:title="@string/type"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_filter_field.xml b/app/src/main/res/layout/view_filter_field.xml new file mode 100644 index 000000000..5478bd1ed --- /dev/null +++ b/app/src/main/res/layout/view_filter_field.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 690f1081d..113f67fc6 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -172,4 +172,9 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b219423ef..9fef1ac61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -724,4 +724,6 @@ Shoujo Seinen Josei + Years + Any From e0c983f4ebae9dc30f4799a145f50cf570019b04 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 22 Sep 2024 17:42:28 +0300 Subject: [PATCH 15/71] Search manga with filters --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 5 +- .../alternatives/ui/AlternativesActivity.kt | 12 +- .../org/koitharu/kotatsu/core/model/Manga.kt | 27 ++++ .../parcelable/ParcelableMangaListFilter.kt | 53 +++++++ .../kotatsu/core/os/AppShortcutManager.kt | 2 +- .../kotatsu/core/ui/widgets/ChipsView.kt | 8 +- .../koitharu/kotatsu/core/util/ext/Bundle.kt | 26 ++++ .../kotatsu/core/util/ext/Collections.kt | 6 + .../kotatsu/details/ui/DetailsActivity.kt | 10 +- .../ui/worker/DownloadNotificationFactory.kt | 2 +- .../kotatsu/explore/ui/ExploreFragment.kt | 4 +- .../kotatsu/filter/ui/FilterCoordinator.kt | 4 +- .../kotatsu/filter/ui/FilterHeaderFragment.kt | 19 ++- .../kotatsu/filter/ui/FilterHeaderProducer.kt | 21 ++- .../kotatsu/list/ui/MangaListFragment.kt | 4 +- .../list/ui/preview/PreviewFragment.kt | 8 +- .../kotatsu/local/ui/LocalListFragment.kt | 2 + .../kotatsu/local/ui/LocalListViewModel.kt | 8 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 5 +- .../remotelist/ui/MangaSearchMenuProvider.kt | 69 ++++++++++ .../remotelist/ui/RemoteListFragment.kt | 50 +------ .../remotelist/ui/RemoteListViewModel.kt | 3 - .../kotatsu/search/ui/MangaListActivity.kt | 59 ++++---- .../kotatsu/search/ui/SearchActivity.kt | 97 ------------- .../kotatsu/search/ui/SearchFragment.kt | 37 ----- .../kotatsu/search/ui/SearchViewModel.kt | 129 ------------------ .../search/ui/multi/MultiSearchActivity.kt | 12 +- .../sources/catalog/SourcesCatalogActivity.kt | 2 +- app/src/main/res/layout/activity_search.xml | 41 ------ app/src/main/res/menu/opt_list_remote.xml | 8 +- app/src/main/res/menu/opt_search.xml | 14 ++ app/src/main/res/values/strings.xml | 1 + 33 files changed, 309 insertions(+), 441 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/MangaSearchMenuProvider.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt delete mode 100644 app/src/main/res/layout/activity_search.xml create mode 100644 app/src/main/res/menu/opt_search.xml diff --git a/app/build.gradle b/app/build.gradle index e8093e261..0daab315f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:f2354957e6') { + implementation('com.github.KotatsuApp:kotatsu-parsers:f410df40f1') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c0f146385..31d3ffc16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,7 +68,7 @@ + android:value="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity" /> - (), override fun onItemClick(item: MangaAlternativeModel, view: View) { when (view.id) { - R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title)) + R.id.chip_source -> startActivity( + MangaListActivity.newIntent( + this, + item.manga.source, + MangaListFilter(query = viewModel.manga.title), + ), + ) + R.id.button_migrate -> confirmMigration(item.manga) else -> startActivity(DetailsActivity.newIntent(this, item.manga)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index aa5bd3330..78c4bd3da 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -1,10 +1,13 @@ package org.koitharu.kotatsu.core.model import android.net.Uri +import android.text.SpannableStringBuilder import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.collection.MutableObjectIntMap import androidx.core.os.LocaleListCompat +import androidx.core.text.buildSpannedString +import androidx.core.text.strikeThrough import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.details.ui.model.ChapterListItem @@ -12,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.util.formatSimple import org.koitharu.kotatsu.parsers.util.mapToSet @@ -152,3 +156,26 @@ fun Manga.chaptersCount(): Int { } return max } + +fun MangaListFilter.getSummary() = buildSpannedString { + if (!query.isNullOrEmpty()) { + append(query) + if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) { + append(' ') + append('(') + appendTagsSummary(this@getSummary) + append(')') + } + } else { + appendTagsSummary(this@getSummary) + } +} + +private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) { + filter.tags.joinTo(this) { it.title } + if (filter.tagsExclude.isNotEmpty()) { + strikeThrough { + filter.tagsExclude.joinTo(this) { it.title } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt new file mode 100644 index 000000000..6b0eb85ed --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import org.koitharu.kotatsu.core.util.ext.readEnumSet +import org.koitharu.kotatsu.core.util.ext.readParcelableCompat +import org.koitharu.kotatsu.core.util.ext.readSerializableCompat +import org.koitharu.kotatsu.core.util.ext.writeEnumSet +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaState + +object MangaListFilterParceler : Parceler { + + override fun MangaListFilter.write(parcel: Parcel, flags: Int) { + parcel.writeString(query) + parcel.writeParcelable(ParcelableMangaTags(tags), 0) + parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0) + parcel.writeSerializable(locale) + parcel.writeSerializable(originalLocale) + parcel.writeEnumSet(states) + parcel.writeEnumSet(contentRating) + parcel.writeEnumSet(types) + parcel.writeEnumSet(demographics) + parcel.writeInt(year) + parcel.writeInt(yearFrom) + parcel.writeInt(yearTo) + } + + override fun create(parcel: Parcel) = MangaListFilter( + query = parcel.readString(), + tags = parcel.readParcelableCompat()?.tags.orEmpty(), + tagsExclude = parcel.readParcelableCompat()?.tags.orEmpty(), + locale = parcel.readSerializableCompat(), + originalLocale = parcel.readSerializableCompat(), + states = parcel.readEnumSet().orEmpty(), + contentRating = parcel.readEnumSet().orEmpty(), + types = parcel.readEnumSet().orEmpty(), + demographics = parcel.readEnumSet().orEmpty(), + year = parcel.readInt(), + yearFrom = parcel.readInt(), + yearTo = parcel.readInt(), + ) +} + +@Parcelize +@TypeParceler +data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt index 71aad8f3f..414a8a24f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt @@ -180,7 +180,7 @@ class AppShortcutManager @Inject constructor( .setLongLabel(title) .setIcon(icon) .setLongLived(true) - .setIntent(MangaListActivity.newIntent(context, source)) + .setIntent(MangaListActivity.newIntent(context, source, null)) .build() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index 873746bce..bb0f7972d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -36,11 +36,6 @@ class ChipsView @JvmOverloads constructor( children.forEach { it.isClickable = isChipClickable } } var onChipCloseClickListener: OnChipCloseClickListener? = null - set(value) { - field = value - val isCloseIconVisible = value != null - children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible } - } init { val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) @@ -98,6 +93,7 @@ class ChipsView @JvmOverloads constructor( @ColorRes val tint: Int = 0, val isChecked: Boolean = false, val isDropdown: Boolean = false, + val isCloseable: Boolean = false, val data: Any? = null, ) @@ -139,7 +135,7 @@ class ChipsView @JvmOverloads constructor( isChipIconVisible = true } isCheckedIconVisible = model.isChecked - isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) { + isCloseIconVisible = if (model.isCloseable || model.isDropdown) { setCloseIconResource( if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt index 8933ae4db..34f3f440e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt @@ -12,6 +12,7 @@ import androidx.core.os.BundleCompat import androidx.core.os.ParcelCompat import androidx.lifecycle.SavedStateHandle import java.io.Serializable +import java.util.EnumSet // https://issuetracker.google.com/issues/240585930 @@ -53,6 +54,31 @@ inline fun Bundle.requireSerializable(key: String): T } } +fun > Parcel.writeEnumSet(set: Set?) { + if (set == null) { + writeValue(null) + } else { + val array = IntArray(set.size) + set.forEachIndexed { i, e -> array[i] = e.ordinal } + writeIntArray(array) + } +} + +inline fun > Parcel.readEnumSet(): Set? = readEnumSet(E::class.java) + +fun > Parcel.readEnumSet(cls: Class): Set? { + val array = createIntArray() ?: return null + if (array.isEmpty()) { + return emptySet() + } + val enumValues = cls.enumConstants ?: return null + val set = EnumSet.noneOf(cls) + array.forEach { e -> + set.add(enumValues[e]) + } + return set +} + fun SavedStateHandle.require(key: String): T { return checkNotNull(get(key)) { "Value $key not found in SavedStateHandle or has a wrong type" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt index edcb7845a..109d605a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt @@ -25,6 +25,12 @@ fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { ArrayList(this) } +fun > Set.asEnumSet(cls: Class): EnumSet = if (this is EnumSet<*>) { + this as EnumSet +} else { + EnumSet.noneOf(cls).apply { addAll(this@asEnumSet) } +} + fun Map.findKeyByValue(value: V): K? { for ((k, v) in entries) { if (v == value) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 0d689804f..6a3dfab94 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -98,13 +98,13 @@ import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import javax.inject.Inject import com.google.android.material.R as materialR @@ -213,10 +213,10 @@ class DetailsActivity : R.id.chip_author -> { val manga = viewModel.manga.value ?: return startActivity( - SearchActivity.newIntent( + MangaListActivity.newIntent( context = v.context, source = manga.source, - query = manga.author ?: return, + filter = MangaListFilter(query = manga.author), ), ) } @@ -227,6 +227,7 @@ class DetailsActivity : MangaListActivity.newIntent( context = v.context, source = manga.source, + filter = null, ), ) } @@ -286,7 +287,8 @@ class DetailsActivity : override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return - startActivity(MangaListActivity.newIntent(this, setOf(tag))) + // TODO dialog + startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag)))) } override fun onLongClick(v: View): Boolean = when (v.id) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 05cdbd931..03f23e5cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -265,7 +265,7 @@ class DownloadNotificationFactory @AssistedInject constructor( if (manga != null) { DetailsActivity.newIntent(context, manga) } else { - MangaListActivity.newIntent(context, LocalMangaSource) + MangaListActivity.newIntent(context, LocalMangaSource, null) }, PendingIntent.FLAG_CANCEL_CURRENT, false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 743253b59..2f4ea0c24 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -126,7 +126,7 @@ class ExploreFragment : override fun onClick(v: View) { val intent = when (v.id) { - R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource) + R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource, null) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context) R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java) @@ -144,7 +144,7 @@ class ExploreFragment : if (sourceSelectionController?.onItemClick(item.id) == true) { return } - val intent = MangaListActivity.newIntent(view.context, item.source) + val intent = MangaListActivity.newIntent(view.context, item.source, null) startActivity(intent) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 059546880..0bdbae1f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -59,8 +59,8 @@ class FilterCoordinator @Inject constructor( private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder) private val availableSortOrders = repository.sortOrders - private val capabilities = repository.filterCapabilities private val filterOptions = SuspendLazy { repository.getFilterOptions() } + val capabilities = repository.filterCapabilities val mangaSource: MangaSource get() = repository.source @@ -69,7 +69,7 @@ class FilterCoordinator @Inject constructor( get() = !currentListFilter.value.isEmpty() val query: StateFlow = currentListFilter.map { it.query } - .stateIn(coroutineScope, SharingStarted.Lazily, null) + .stateIn(coroutineScope, SharingStarted.Eagerly, null) val sortOrder: StateFlow> = currentSortOrder.map { selected -> FilterProperty( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index e34b872ad..57662e12c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -22,7 +22,8 @@ import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint -class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener { +class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener, + ChipsView.OnChipCloseClickListener { @Inject lateinit var filterHeaderProducer: FilterHeaderProducer @@ -37,6 +38,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) binding.chipsTags.onChipClickListener = this + binding.chipsTags.onChipCloseClickListener = this filterHeaderProducer.observeHeader(filter) .flowOn(Dispatchers.Default) .observe(viewLifecycleOwner, ::onDataChanged) @@ -45,11 +47,16 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag - if (tag == null) { - TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) - } else { - filter.toggleTag(tag, !chip.isChecked) + when (data) { + is MangaTag -> filter.toggleTag(data, !chip.isChecked) + is String -> Unit + null -> TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) + } + } + + override fun onChipCloseClick(chip: Chip, data: Any?) { + when (data) { + is String -> filter.setQuery(null) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index 02ea5169f..f43811745 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -2,25 +2,25 @@ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import java.util.LinkedList import javax.inject.Inject +import com.google.android.material.R as materialR class FilterHeaderProducer @Inject constructor( private val searchRepository: MangaSearchRepository, ) { fun observeHeader(filterCoordinator: FilterCoordinator): Flow { - return filterCoordinator.tags.mapLatest { + return combine(filterCoordinator.tags, filterCoordinator.query) { tags, query -> createChipsList( source = filterCoordinator.mangaSource, - property = it, + property = tags, + query = query, limit = 8, ) }.combine(filterCoordinator.observe()) { chipList, snapshot -> @@ -35,6 +35,7 @@ class FilterHeaderProducer @Inject constructor( private suspend fun createChipsList( source: MangaSource, property: FilterProperty, + query: String?, limit: Int, ): List { val selectedTags = property.selectedItems.toMutableSet() @@ -49,7 +50,7 @@ class FilterHeaderProducer @Inject constructor( if (tags.isEmpty() && selectedTags.isEmpty()) { return emptyList() } - val result = LinkedList() + val result = ArrayDeque(tags.size + selectedTags.size + 1) for (tag in tags) { val model = ChipsView.ChipModel( title = tag.title, @@ -70,6 +71,16 @@ class FilterHeaderProducer @Inject constructor( ) result.addFirst(model) } + if (!query.isNullOrEmpty()) { + result.addFirst( + ChipsView.ChipModel( + title = query, + icon = materialR.drawable.abc_ic_search_api_material, + isCloseable = true, + data = query, + ), + ) + } return result } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 3a9f02614..9184b0f8a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -60,6 +60,7 @@ import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity @@ -164,7 +165,8 @@ abstract class MangaListFragment : override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (selectionController?.onItemClick(manga.id) != true) { - val intent = MangaListActivity.newIntent(context ?: return, setOf(tag)) + // TODO dialog + val intent = MangaListActivity.newIntent(view.context, tag.source, MangaListFilter(tags = setOf(tag))) startActivity(intent) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index 4512e2488..132f69382 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -31,10 +31,10 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.SearchActivity import javax.inject.Inject @AndroidEntryPoint @@ -83,10 +83,10 @@ class PreviewFragment : BaseFragment(), View.OnClickList } R.id.textView_author -> startActivity( - SearchActivity.newIntent( + MangaListActivity.newIntent( context = v.context, source = manga.source, - query = manga.author ?: return, + filter = MangaListFilter(query = manga.author), ), ) @@ -107,7 +107,7 @@ class PreviewFragment : BaseFragment(), View.OnClickList val tag = data as? MangaTag ?: return val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator if (filter == null) { - startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) + startActivity(MangaListActivity.newIntent(chip.context, tag.source, MangaListFilter(tags = setOf(tag)))) } else { filter.toggleTag(tag, true) closeSelf() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 44794afa4..41b7307b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -26,6 +26,7 @@ import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity @@ -61,6 +62,7 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick)) + addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index ac8993e9e..08217f7fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -16,7 +16,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.FilterHeaderProducer import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel @@ -41,7 +40,6 @@ class LocalListViewModel @Inject constructor( exploreRepository: ExploreRepository, @LocalStorageChanges private val localStorageChanges: SharedFlow, private val localStorageManager: LocalStorageManager, - filterHeaderProducer: FilterHeaderProducer, sourcesRepository: MangaSourcesRepository, ) : RemoteListViewModel( savedStateHandle, @@ -109,8 +107,10 @@ class LocalListViewModel @Inject constructor( } } - override fun createEmptyState(canResetFilter: Boolean): EmptyState { - return EmptyState( + override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) { + super.createEmptyState(canResetFilter) + } else { + EmptyState( icon = R.drawable.ic_empty_local, textPrimary = R.string.text_local_holder_primary, textSecondary = R.string.text_local_holder_secondary, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 0db277b0b..3360ab460 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -60,6 +60,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner 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.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder @@ -265,7 +266,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onTagClick(tag: MangaTag) { - startActivity(MangaListActivity.newIntent(this, setOf(tag))) + startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag)))) } override fun onQueryChanged(query: String) { @@ -277,7 +278,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onSourceClick(source: MangaSource) { - val intent = MangaListActivity.newIntent(this, source) + val intent = MangaListActivity.newIntent(this, source, null) startActivity(intent) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/MangaSearchMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/MangaSearchMenuProvider.kt new file mode 100644 index 000000000..08dc09970 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/MangaSearchMenuProvider.kt @@ -0,0 +1,69 @@ +package org.koitharu.kotatsu.remotelist.ui + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider +import androidx.core.view.inputmethod.EditorInfoCompat +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.parsers.model.MangaListFilter + +class MangaSearchMenuProvider( + private val filter: FilterCoordinator, + private val viewModel: MangaListViewModel, +) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_search, menu) + val menuItem = menu.findItem(R.id.action_search) + menuItem.setOnActionExpandListener(this) + val searchView = menuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.queryHint = menuItem.title + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.action_search)?.isVisible = filter.capabilities.isSearchSupported + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false + + override fun onQueryTextSubmit(query: String?): Boolean { + val snapshot = filter.snapshot() + if (!query.isNullOrEmpty() && !filter.capabilities.isSearchWithFiltersSupported && snapshot.listFilter.hasNonSearchOptions()) { + filter.set(MangaListFilter(query = query)) + viewModel.onActionDone.call( + ReversibleAction(R.string.filter_search_warning) { filter.set(snapshot.listFilter) }, + ) + } else { + filter.setQuery(query) + } + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + (item.actionView as? SearchView)?.run { + post { adjustSearchView() } + } + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean = true + + private fun SearchView.adjustSearchView() { + imeOptions = if (viewModel.isIncognitoModeEnabled) { + imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + setQuery(filter.query.value, false) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index b8d7ff663..5776e1444 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -6,9 +6,7 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider -import androidx.core.view.inputmethod.EditorInfoCompat import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -28,9 +26,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint @@ -44,6 +40,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addMenuProvider(RemoteListMenuProvider()) + addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { startActivity(DetailsActivity.newIntent(binding.root.context, it)) @@ -86,19 +83,10 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { .show() } - private inner class RemoteListMenuProvider : - MenuProvider, - SearchView.OnQueryTextListener, - MenuItem.OnActionExpandListener { + private inner class RemoteListMenuProvider : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_list_remote, menu) - val searchMenuItem = menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { @@ -127,43 +115,9 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) - menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value menu.findItem(R.id.action_filter_reset)?.isVisible = filterCoordinator.isFilterApplied } - - override fun onQueryTextSubmit(query: String?): Boolean { - if (query.isNullOrEmpty()) { - return false - } - val intent = SearchActivity.newIntent( - context = this@RemoteListFragment.context ?: return false, - source = viewModel.source, - query = query, - ) - startActivity(intent) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean = false - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - (item.actionView as? SearchView)?.run { - imeOptions = if (viewModel.isIncognitoModeEnabled) { - imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } else { - imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() - } - } - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - val searchView = (item.actionView as? SearchView) ?: return false - searchView.setQuery("", false) - return true - } } companion object { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 30906a4c5..3727776b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -70,9 +70,6 @@ open class RemoteListViewModel @Inject constructor( private var loadingJob: Job? = null private var randomJob: Job? = null - val isSearchAvailable: Boolean - get() = repository.filterCapabilities.isSearchSupported - val browserUrl: String? get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index d7bd95c92..989828470 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -23,10 +23,11 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes @@ -45,7 +46,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.isNullOrEmpty import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import kotlin.math.absoluteValue import com.google.android.material.R as materialR @@ -63,28 +64,23 @@ class MangaListActivity : "Cannot find FilterCoordinator.Owner fragment in ${supportFragmentManager.fragments}" }.filterCoordinator - private var source: MangaSource? = null + private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaListBinding.inflate(layoutInflater)) - val tags = intent.getParcelableExtraCompat(EXTRA_TAGS)?.tags + val filter = intent.getParcelableExtraCompat(EXTRA_FILTER)?.filter + source = MangaSource(intent.getStringExtra(EXTRA_SOURCE)) supportActionBar?.setDisplayHomeAsUpEnabled(true) if (viewBinding.containerFilterHeader != null) { viewBinding.appbar.addOnOffsetChangedListener(this) } - source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source - val src = source - if (src == null) { - finishAfterTransition() - } else { - viewBinding.buttonOrder?.setOnClickListener(this) - title = src.getTitle(this) - initList(src, tags) - } + viewBinding.buttonOrder?.setOnClickListener(this) + title = source.getTitle(this) + initList(source, filter) } - override fun isNsfwContent(): Flow = flowOf(source?.isNsfw() == true) + override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) override fun onWindowInsetsChanged(insets: Insets) { viewBinding.root.updatePadding( @@ -119,7 +115,7 @@ class MangaListActivity : fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) - private fun initList(source: MangaSource, tags: Set?) { + private fun initList(source: MangaSource, filter: MangaListFilter?) { val fm = supportFragmentManager val existingFragment = fm.findFragmentById(R.id.container) if (existingFragment is FilterCoordinator.Owner) { @@ -134,8 +130,8 @@ class MangaListActivity : } replace(R.id.container, fragment) runOnCommit { initFilter(fragment) } - if (!tags.isNullOrEmpty()) { - runOnCommit(ApplyFilterRunnable(fragment, tags)) + if (filter != null) { + runOnCommit(ApplyFilterRunnable(fragment, filter)) } } } @@ -161,11 +157,12 @@ class MangaListActivity : filterBadge.setMaxCharacterCount(0) filter.observe().observe(this) { snapshot -> chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) - filterBadge.counter = if (snapshot.listFilter.isEmpty()) 0 else 1 + filterBadge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 + supportActionBar?.subtitle = snapshot.listFilter.query } } else { filter.observe().map { - it.listFilter.tags.joinToString { tag -> tag.title } + it.listFilter.getSummary() }.flowOn(Dispatchers.Default) .observe(this) { supportActionBar?.subtitle = it @@ -189,26 +186,28 @@ class MangaListActivity : private class ApplyFilterRunnable( private val filterOwner: FilterCoordinator.Owner, - private val tags: Set, + private val filter: MangaListFilter, ) : Runnable { override fun run() { - filterOwner.filterCoordinator.set(MangaListFilter(tags = tags)) + filterOwner.filterCoordinator.set(filter) } } companion object { - private const val EXTRA_TAGS = "tags" + private const val EXTRA_FILTER = "filter" private const val EXTRA_SOURCE = "source" - const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" + private const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" - fun newIntent(context: Context, tags: Set) = Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_TAGS, ParcelableMangaTags(tags)) - - fun newIntent(context: Context, source: MangaSource) = Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_SOURCE, source.name) + fun newIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent = + Intent(context, MangaListActivity::class.java) + .setAction(ACTION_MANGA_EXPLORE) + .putExtra(EXTRA_SOURCE, source.name) + .apply { + if (!filter.isNullOrEmpty()) { + putExtra(EXTRA_FILTER, ParcelableMangaListFilter(filter)) + } + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt deleted file mode 100644 index ec63cf80e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.koitharu.kotatsu.search.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.core.graphics.Insets -import androidx.core.view.SoftwareKeyboardControllerCompat -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.databinding.ActivitySearchBinding -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel - -@AndroidEntryPoint -class SearchActivity : BaseActivity(), SearchView.OnQueryTextListener { - - private val searchSuggestionViewModel by viewModels() - private lateinit var source: MangaSource - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivitySearchBinding.inflate(layoutInflater)) - source = MangaSource(intent.getStringExtra(EXTRA_SOURCE)) - val query = intent.getStringExtra(EXTRA_QUERY) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) - with(viewBinding.searchView) { - queryHint = getString(R.string.search_on_s, source.getTitle(context)) - setOnQueryTextListener(this@SearchActivity) - - if (query.isNullOrBlank()) { - requestFocus() - SoftwareKeyboardControllerCompat(this).show() - } else { - setQuery(query, true) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - top = insets.top, - ) - viewBinding.container.updatePadding( - bottom = insets.bottom, - ) - } - - override fun onQueryTextSubmit(query: String?): Boolean { - val q = query?.trim() - if (q.isNullOrEmpty()) { - return false - } - title = query - supportFragmentManager.commit { - setReorderingAllowed(true) - replace(R.id.container, SearchFragment.newInstance(source, q)) - } - viewBinding.searchView.clearFocus() - searchSuggestionViewModel.saveQuery(q) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean = false - - private fun onIncognitoModeChanged(isIncognito: Boolean) { - var options = viewBinding.searchView.imeOptions - options = if (isIncognito) { - options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } else { - options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() - } - viewBinding.searchView.imeOptions = options - } - - companion object { - - private const val EXTRA_SOURCE = "source" - private const val EXTRA_QUERY = "query" - - fun newIntent(context: Context, source: MangaSource, query: String?) = - Intent(context, SearchActivity::class.java) - .putExtra(EXTRA_SOURCE, source.name) - .putExtra(EXTRA_QUERY, query) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt deleted file mode 100644 index 7662f9eb9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.search.ui - -import android.view.Menu -import androidx.appcompat.view.ActionMode -import androidx.fragment.app.viewModels -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.MangaSource - -@AndroidEntryPoint -class SearchFragment : MangaListFragment() { - - override val viewModel by viewModels() - - override fun onScrolledToEnd() { - viewModel.loadNextPage() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - companion object { - - const val ARG_QUERY = "query" - const val ARG_SOURCE = "source" - - fun newInstance(source: MangaSource, query: String) = SearchFragment().withArgs(2) { - putString(ARG_SOURCE, source.name) - putString(ARG_QUERY, query) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt deleted file mode 100644 index e1ceb0db2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.koitharu.kotatsu.search.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -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.R -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.distinctById -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.core.util.ext.sizeOrZero -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.domain.MangaListMapper -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorFooter -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import javax.inject.Inject - -@HiltViewModel -class SearchViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - repositoryFactory: MangaRepository.Factory, - settings: AppSettings, - private val mangaListMapper: MangaListMapper, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { - - private val query = savedStateHandle.require(SearchFragment.ARG_QUERY) - private val repository = repositoryFactory.create(MangaSource(savedStateHandle[SearchFragment.ARG_SOURCE])) - private val mangaList = MutableStateFlow?>(null) - private val hasNextPage = MutableStateFlow(false) - private val listError = MutableStateFlow(null) - private var loadingJob: Job? = null - - override val content = combine( - mangaList.map { it?.skipNsfwIfNeeded() }, - observeListModeWithTriggers(), - listError, - hasNextPage, - ) { list, mode, error, hasNext -> - when { - list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) - list == null -> listOf(LoadingState) - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_search_holder_secondary, - actionStringRes = 0, - ), - ) - - else -> { - val result = ArrayList(list.size + 1) - mangaListMapper.toListModelList(result, list, mode) - when { - error != null -> result += error.toErrorFooter() - hasNext -> result += LoadingFooter() - } - result - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - init { - loadList(append = false) - } - - override fun onRefresh() { - loadList(append = false) - } - - override fun onRetry() { - loadList(append = !mangaList.value.isNullOrEmpty()) - } - - fun loadNextPage() { - if (hasNextPage.value && listError.value == null) { - loadList(append = true) - } - } - - private fun loadList(append: Boolean) { - if (loadingJob?.isActive == true) { - return - } - loadingJob = launchLoadingJob(Dispatchers.Default) { - try { - listError.value = null - val list = repository.getList( - offset = if (append) mangaList.value.sizeOrZero() else 0, - order = null, - filter = MangaListFilter(query = query), - ) - val prevList = mangaList.value.orEmpty() - if (!append) { - mangaList.value = list.distinctById() - } else if (list.isNotEmpty()) { - mangaList.value = (prevList + list).distinctById() - } - hasNextPage.value = if (append) { - prevList != mangaList.value - } else { - list.isNotEmpty() - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - listError.value = e - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index eed362e67..a719c4f20 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -34,10 +34,10 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter import javax.inject.Inject @@ -63,7 +63,13 @@ class MultiSearchActivity : title = viewModel.query val itemCLickListener = OnListItemClickListener { item, view -> - startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query)) + startActivity( + MangaListActivity.newIntent( + view.context, + item.source, + MangaListFilter(query = viewModel.query), + ), + ) } val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) val selectionDecoration = MangaSelectionDecoration(this) @@ -125,7 +131,7 @@ class MultiSearchActivity : override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = MangaListActivity.newIntent(this, setOf(tag)) + val intent = MangaListActivity.newIntent(this, manga.source, MangaListFilter(tags = setOf(tag))) startActivity(intent) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index 30005249b..a655cb410 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -89,7 +89,7 @@ class SourcesCatalogActivity : BaseActivity(), } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { - startActivity(MangaListActivity.newIntent(this, item.source)) + startActivity(MangaListActivity.newIntent(this, item.source, null)) } override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml deleted file mode 100644 index 494f0935a..000000000 --- a/app/src/main/res/layout/activity_search.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/opt_list_remote.xml b/app/src/main/res/menu/opt_list_remote.xml index 768334fea..c1f6cdde1 100644 --- a/app/src/main/res/menu/opt_list_remote.xml +++ b/app/src/main/res/menu/opt_list_remote.xml @@ -4,16 +4,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> - - diff --git a/app/src/main/res/menu/opt_search.xml b/app/src/main/res/menu/opt_search.xml new file mode 100644 index 000000000..49d226ff9 --- /dev/null +++ b/app/src/main/res/menu/opt_search.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fef1ac61..6f78da58e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -726,4 +726,5 @@ Josei Years Any + This source does not support search with filters. Your filters have been cleared From 36bd3cc43844f24e914934adfdca29d94babff0b Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 23 Sep 2024 08:55:17 +0300 Subject: [PATCH 16/71] Local manga index in database --- app/src/main/AndroidManifest.xml | 1 + .../org/koitharu/kotatsu/core/BaseApp.kt | 12 +++ .../koitharu/kotatsu/core/db/MangaDatabase.kt | 10 +- .../core/db/migrations/Migration22To23.kt | 11 ++ .../download/ui/worker/DownloadWorker.kt | 1 + .../kotatsu/favourites/data/FavouritesDao.kt | 1 + .../favourites/domain/FavouritesRepository.kt | 4 +- .../domain/LocalFavoritesObserver.kt | 15 +-- .../kotatsu/history/data/HistoryDao.kt | 1 + .../history/data/HistoryLocalObserver.kt | 11 +- .../kotatsu/history/data/HistoryRepository.kt | 2 +- .../local/data/LocalMangaMappingCache.kt | 32 ------ .../local/data/LocalMangaRepository.kt | 36 ++++--- .../local/data/index/LocalMangaIndex.kt | 100 ++++++++++++++++++ .../local/data/index/LocalMangaIndexDao.kt | 22 ++++ .../local/data/index/LocalMangaIndexEntity.kt | 24 +++++ .../local/domain/LocalObserveMapper.kt | 41 ++----- .../local/ui/LocalIndexUpdateService.kt | 20 ++++ .../koitharu/kotatsu/main/ui/MainActivity.kt | 2 + 19 files changed, 248 insertions(+), 98 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31d3ffc16..b315e91c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -273,6 +273,7 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt index 4a31e64b7..fb37b2cb6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt @@ -11,6 +11,7 @@ import androidx.work.Configuration import androidx.work.WorkManager import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA @@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.ext.processLifecycleScope +import org.koitharu.kotatsu.local.data.LocalStorageChanges +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.settings.work.WorkScheduleManager import java.security.Security import javax.inject.Inject @@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider { @Inject lateinit var workManagerProvider: Provider + @Inject + lateinit var localMangaIndexProvider: Provider + + @Inject + @LocalStorageChanges + lateinit var localStorageChanges: MutableSharedFlow + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider { } processLifecycleScope.launch(Dispatchers.Default) { setupDatabaseObservers() + localStorageChanges.collect(localMangaIndexProvider.get()) } workScheduleManager.init() WorkServiceStopHelper(workManagerProvider).setup() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 1bec0c9b8..60f1abb02 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20 import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration20To21 import org.koitharu.kotatsu.core.db.migrations.Migration21To22 +import org.koitharu.kotatsu.core.db.migrations.Migration22To23 import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration4To5 @@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao +import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity import org.koitharu.kotatsu.stats.data.StatsDao @@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TracksDao -const val DATABASE_VERSION = 22 +const val DATABASE_VERSION = 23 @Database( entities = [ MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, - ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, + ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ], version = DATABASE_VERSION, ) @@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract fun getSourcesDao(): MangaSourcesDao abstract fun getStatsDao(): StatsDao + + abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao } fun getDatabaseMigrations(context: Context): Array = arrayOf( @@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration19To20(), Migration20To21(), Migration21To22(), + Migration22To23(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt new file mode 100644 index 000000000..3ead2c4ff --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration22To23 : Migration(22, 23) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index c678d25d4..c62b7f807 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -71,6 +71,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.TempFileFilter +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.domain.MangaLock diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index b8e444a2d..fe62f0334 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -199,6 +199,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" + ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)" else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 85f654492..1e0d91693 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -42,7 +42,7 @@ class FavouritesRepository @Inject constructor( fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit) + return localObserver.observeAll(order, filterOptions, limit) } return db.getFavouritesDao().observeAll(order, filterOptions, limit) .mapItems { it.toManga() } @@ -60,7 +60,7 @@ class FavouritesRepository @Inject constructor( limit: Int ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(categoryId, order, filterOptions - ListFilterOption.Downloaded, limit) + return localObserver.observeAll(categoryId, order, filterOptions, limit) } return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit) .mapItems { it.toManga() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt index 0bee8280c..6fcacd4d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt @@ -2,29 +2,30 @@ package org.koitharu.kotatsu.favourites.domain import dagger.Reusable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.favourites.data.FavouriteManga import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @Reusable class LocalFavoritesObserver @Inject constructor( - localMangaRepository: LocalMangaRepository, + localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, -) : LocalObserveMapper(localMangaRepository, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex, limitStep = 10) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = observe(limit) { newLimit -> - db.getFavouritesDao().observeAll(order, filterOptions, newLimit) + ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapLatest { + it.mapToLocal() } fun observeAll( @@ -32,8 +33,8 @@ class LocalFavoritesObserver @Inject constructor( order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = observe(limit) { newLimit -> - db.getFavouritesDao().observeAll(categoryId, order, filterOptions, newLimit) + ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapLatest { + it.mapToLocal() } override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index 1389e6524..23e0db88e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -159,6 +159,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" + ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)" else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt index e1324a2a3..00a95dc28 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt @@ -1,29 +1,30 @@ package org.koitharu.kotatsu.history.data import dagger.Reusable +import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.domain.LocalObserveMapper import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @Reusable class HistoryLocalObserver @Inject constructor( - localMangaRepository: LocalMangaRepository, + localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, -) : LocalObserveMapper(localMangaRepository, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex, limitStep = 10) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int - ) = observe(limit) { newLimit -> - db.getHistoryDao().observeAll(order, filterOptions, newLimit) + ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapLatest { + it.mapToLocal() } override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 03ccd91e5..6e323bd32 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -82,7 +82,7 @@ class HistoryRepository @Inject constructor( limit: Int ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit) + return localObserver.observeAll(order, filterOptions, limit) } return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems { MangaWithHistory( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt deleted file mode 100644 index 7ef175a0d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import androidx.collection.MutableLongObjectMap -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.io.File - -class LocalMangaMappingCache { - - private val map = MutableLongObjectMap() - - suspend fun get(mangaId: Long): LocalManga? { - val file = synchronized(this) { - map[mangaId] - } ?: return null - return runCatchingCancellable { - LocalMangaInput.of(file).getManga() - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - } - - operator fun set(mangaId: Long, localManga: LocalManga?) = synchronized(this) { - if (localManga == null) { - map.remove(mangaId) - } else { - map[mangaId] = localManga.file - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 54f8f0030..d43bf2805 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -3,12 +3,11 @@ package org.koitharu.kotatsu.local.data import android.net.Uri import androidx.core.net.toFile import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toCollection import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.LocalMangaSource @@ -20,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.filterWith import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil @@ -43,13 +43,13 @@ private const val MAX_PARALLELISM = 4 @Singleton class LocalMangaRepository @Inject constructor( private val storageManager: LocalStorageManager, + private val localMangaIndex: LocalMangaIndex, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val settings: AppSettings, private val lock: MangaLock, ) : MangaRepository { override val source = LocalMangaSource - private val localMappingCache = LocalMangaMappingCache() override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities( @@ -116,6 +116,7 @@ class LocalMangaRepository @Inject constructor( val file = Uri.parse(manga.url).toFile() val result = file.deleteAwait() if (result) { + localMangaIndex.delete(manga.id) localStorageChanges.emit(null) } return result @@ -139,7 +140,7 @@ class LocalMangaRepository @Inject constructor( suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable { // very fast path - localMappingCache.get(remoteManga.id)?.let { + localMangaIndex.get(remoteManga.id)?.let { return@runCatchingCancellable it } // fast path @@ -164,7 +165,9 @@ class LocalMangaRepository @Inject constructor( } }.firstOrNull()?.getManga() }.onSuccess { x: LocalManga? -> - localMappingCache[remoteManga.id] = x + if (x != null) { + localMangaIndex.put(x) + } }.onFailure { it.printStackTraceDebug() }.getOrNull() @@ -199,18 +202,21 @@ class LocalMangaRepository @Inject constructor( return true } - private suspend fun getRawList(): ArrayList { - val files = getAllFiles().toList() // TODO remove toList() - return coroutineScope { - val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) - files.map { file -> - async(dispatcher) { - runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull() + fun getRawListAsFlow(): Flow = channelFlow { + val files = getAllFiles() + val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) + for (file in files) { + launch(dispatcher) { + val m = LocalMangaInput.ofOrNull(file)?.getManga() + if (m != null) { + send(m) } - }.awaitAll() - }.filterNotNullTo(ArrayList(files.size)) + } + } } + private suspend fun getRawList(): ArrayList = getRawListAsFlow().toCollection(ArrayList()) + private suspend fun getAllFiles() = storageManager.getReadableDirs() .asSequence() .flatMap { dir -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt new file mode 100644 index 000000000..9a9c08f43 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -0,0 +1,100 @@ +package org.koitharu.kotatsu.local.data.index + +import android.content.Context +import androidx.core.content.edit +import androidx.room.withTransaction +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.data.input.LocalMangaInput +import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.File +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class LocalMangaIndex @Inject constructor( + private val mangaDataRepository: MangaDataRepository, + private val db: MangaDatabase, + private val localStorageManager: LocalStorageManager, + @ApplicationContext context: Context, + private val localMangaRepositoryProvider: Provider, +) : FlowCollector { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + private var previousHash: Long + get() = prefs.getLong(KEY_HASH, 0L) + set(value) = prefs.edit { putLong(KEY_HASH, value) } + + override suspend fun emit(value: LocalManga?) { + if (value != null) { + put(value) + } + } + + suspend fun update(): Boolean { + val newHash = computeHash() + if (newHash == previousHash) { + return false + } + db.withTransaction { + val dao = db.getLocalMangaIndexDao() + dao.clear() + localMangaRepositoryProvider.get().getRawListAsFlow() + .collect { dao.upsert(it.toEntity()) } + } + previousHash = newHash + return true + } + + suspend fun get(mangaId: Long): LocalManga? { + val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null + return runCatchingCancellable { + LocalMangaInput.of(File(path)).getManga() + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + suspend fun put(manga: LocalManga) = db.withTransaction { + mangaDataRepository.storeManga(manga.manga) + db.getLocalMangaIndexDao().upsert(manga.toEntity()) + } + + suspend fun delete(mangaId: Long) { + db.getLocalMangaIndexDao().delete(mangaId) + } + + private fun LocalManga.toEntity() = LocalMangaIndexEntity( + mangaId = manga.id, + path = file.path, + ) + + private suspend fun computeHash(): Long { + return runCatchingCancellable { + localStorageManager.getReadableDirs() + .fold(0L) { acc, file -> acc + file.computeHash() } + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(0L) + } + + private suspend fun File.computeHash(): Long = runInterruptible(Dispatchers.IO) { + lastModified() // TODO size + } + + companion object { + + private const val PREF_NAME = "_local_index" + private const val KEY_HASH = "hash" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt new file mode 100644 index 000000000..909a0458c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.local.data.index + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import org.koitharu.kotatsu.core.db.entity.TagEntity + +@Dao +interface LocalMangaIndexDao { + + @Query("SELECT path FROM local_index WHERE manga_id = :mangaId") + suspend fun findPath(mangaId: Long): String? + + @Upsert + suspend fun upsert(entity: LocalMangaIndexEntity) + + @Query("DELETE FROM local_index WHERE manga_id = :mangaId") + suspend fun delete(mangaId: Long) + + @Query("DELETE FROM local_index") + suspend fun clear() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt new file mode 100644 index 000000000..32e159c66 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.local.data.index + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import org.koitharu.kotatsu.core.db.entity.MangaEntity + +@Entity( + tableName = "local_index", + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +class LocalMangaIndexEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "path") val path: String, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt index 0e94293ea..91525dd26 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt @@ -1,56 +1,29 @@ package org.koitharu.kotatsu.local.domain -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.transformLatest import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.parsers.model.Manga -import java.util.Collections -import java.util.WeakHashMap abstract class LocalObserveMapper( - private val localMangaRepository: LocalMangaRepository, + private val localMangaIndex: LocalMangaIndex, private val limitStep: Int, ) { - private val cache = Collections.synchronizedMap(WeakHashMap()) - - protected fun observe(limit: Int, observer: (limit: Int) -> Flow>): Flow> { - val floatingLimit = MutableStateFlow(limit) - return floatingLimit.flatMapLatest { l -> - observer(l) - .transformLatest { fullList -> - val mapped = fullList.mapToLocal(cache) - if (mapped.size < limit && fullList.size == l) { - floatingLimit.value += limitStep - } else { - emit(mapped.take(limit)) - } - }.distinctUntilChanged() - } - } - - private suspend fun List.mapToLocal(cache: MutableMap): List = coroutineScope { - val dispatcher = Dispatchers.IO.limitedParallelism(8) + protected suspend fun List.mapToLocal(): List = coroutineScope { + val dispatcher = Dispatchers.IO.limitedParallelism(6) map { item -> val m = toManga(item) - if (cache.contains(m)) { - CompletableDeferred(cache[m]) - } else async(dispatcher) { + async(dispatcher) { val mapped = if (m.isLocal) { m } else { - localMangaRepository.findSavedManga(m)?.manga + localMangaIndex.get(m.id)?.manga } - mapped?.let { mm -> toResult(item, mm) }.also { cache[m] = it } + mapped?.let { mm -> toResult(item, mm) } } }.awaitAll().filterNotNull() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt new file mode 100644 index 000000000..72e287b17 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.local.ui + +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex +import javax.inject.Inject + +@AndroidEntryPoint +class LocalIndexUpdateService : CoroutineIntentService() { + + @Inject + lateinit var localMangaIndex: LocalMangaIndex + + override suspend fun processIntent(startId: Int, intent: Intent) { + localMangaIndex.update() + } + + override fun onError(startId: Int, error: Throwable) = Unit +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 3360ab460..afd073a9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -55,6 +55,7 @@ import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner @@ -351,6 +352,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav MangaPrefetchService.prefetchLast(this@MainActivity) requestNotificationsPermission() } + startService(Intent(this@MainActivity, LocalMangaIndex::class.java)) } } From 6d84294533a27495b4cc5eea423594003b389a50 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 23 Sep 2024 14:36:29 +0300 Subject: [PATCH 17/71] Improve quick filters --- .../kotatsu/core/model/MangaSource.kt | 22 ++++-- .../core/parser/favicon/FaviconFetcher.kt | 1 + .../kotatsu/core/ui/widgets/ChipsView.kt | 76 +++++++++++++++++-- .../koitharu/kotatsu/core/util/ext/String.kt | 9 +++ .../kotatsu/favourites/data/FavouritesDao.kt | 8 ++ .../domain/FavoritesListQuickFilter.kt | 16 +++- .../favourites/domain/FavouritesRepository.kt | 13 ++++ .../select/adapter/CategoriesHeaderAD.kt | 24 ++++-- .../ui/list/FavouritesListViewModel.kt | 11 ++- .../kotatsu/filter/ui/FilterCoordinator.kt | 2 +- .../kotatsu/history/data/HistoryDao.kt | 5 ++ .../kotatsu/history/data/HistoryRepository.kt | 10 ++- .../history/domain/HistoryListQuickFilter.kt | 3 + .../kotatsu/list/domain/ListFilterOption.kt | 33 ++++++++ .../list/domain/MangaListQuickFilter.kt | 1 + .../kotatsu/list/ui/MangaListFragment.kt | 4 - .../kotatsu/list/ui/MangaListViewModel.kt | 2 - .../list/ui/adapter/MangaListListener.kt | 3 - .../kotatsu/list/ui/model/ListHeader.kt | 2 +- .../local/data/index/LocalMangaIndex.kt | 21 +++-- .../kotatsu/local/ui/LocalListViewModel.kt | 2 +- .../remotelist/ui/RemoteListFragment.kt | 23 ++++-- .../remotelist/ui/RemoteListViewModel.kt | 12 +-- .../search/ui/multi/MultiSearchActivity.kt | 2 - .../kotatsu/suggestions/data/SuggestionDao.kt | 5 ++ .../domain/SuggestionRepository.kt | 7 ++ .../domain/SuggestionsListQuickFilter.kt | 4 +- .../kotatsu/tracker/ui/feed/FeedFragment.kt | 10 +-- app/src/main/res/layout/fragment_feed.xml | 27 ------- .../res/layout/item_categories_header.xml | 18 +++-- app/src/main/res/values/strings.xml | 2 +- 31 files changed, 275 insertions(+), 103 deletions(-) delete mode 100644 app/src/main/res/layout/fragment_feed.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 66c348689..84c12973a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource { return UnknownMangaSource } +fun Collection.toMangaSources() = map(::MangaSource) + fun MangaSource.isNsfw(): Boolean = when (this) { is MangaSourceInfo -> mangaSource.isNsfw() is MangaParserSource -> contentType == ContentType.HENTAI @@ -61,11 +63,16 @@ val ContentType.titleResId ContentType.NOVEL -> R.string.content_type_novel } -fun MangaSource.getSummary(context: Context): String? = when (this) { - is MangaSourceInfo -> mangaSource.getSummary(context) +tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) { + mangaSource.unwrap() +} else { + this +} + +fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) { is MangaParserSource -> { - val type = context.getString(contentType.titleResId) - val locale = locale.toLocale().getDisplayName(context) + val type = context.getString(source.contentType.titleResId) + val locale = source.locale.toLocale().getDisplayName(context) context.getString(R.string.source_summary_pattern, type, locale) } @@ -74,11 +81,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) { else -> null } -fun MangaSource.getTitle(context: Context): String = when (this) { - is MangaSourceInfo -> mangaSource.getTitle(context) - is MangaParserSource -> title +fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) { + is MangaParserSource -> source.title LocalMangaSource -> context.getString(R.string.local_storage) - is ExternalMangaSource -> resolveName(context) + is ExternalMangaSource -> source.resolveName(context) else -> context.getString(R.string.unknown) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index ef155b03e..035e14c9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -114,6 +114,7 @@ class FaviconFetcher( .url(url) .get() .tag(MangaSource::class.java, source) + request.tag(MangaSource::class.java, source) @Suppress("UNCHECKED_CAST") options.tags.asMap().forEach { request.tag(it.key as Class, it.value) } val response = okHttpClient.newCall(request.build()).await() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index bb0f7972d..d22681ecb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -8,18 +8,32 @@ import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.children +import coil.ImageLoader +import coil.request.Disposable +import coil.request.ImageRequest +import coil.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup +import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.image.ChipIconTarget +import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.setProgressIcon +import org.koitharu.kotatsu.parsers.util.ifZero +import javax.inject.Inject import com.google.android.material.R as materialR +@AndroidEntryPoint class ChipsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle, ) : ChipGroup(context, attrs, defStyleAttr) { + @Inject + lateinit var coil: ImageLoader + private var isLayoutSuppressedCompat = false private var isLayoutCalledOnSuppressed = false private val chipOnClickListener = InternalChipClickListener() @@ -90,8 +104,10 @@ class ChipsView @JvmOverloads constructor( val title: CharSequence? = null, @StringRes val titleResId: Int = 0, @DrawableRes val icon: Int = 0, + val iconData: Any? = null, @ColorRes val tint: Int = 0, val isChecked: Boolean = false, + val isLoading: Boolean = false, val isDropdown: Boolean = false, val isCloseable: Boolean = false, val data: Any? = null, @@ -100,6 +116,7 @@ class ChipsView @JvmOverloads constructor( private inner class DataChip(context: Context) : Chip(context) { private var model: ChipModel? = null + private var imageRequest: Disposable? = null init { val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) @@ -112,6 +129,9 @@ class ChipsView @JvmOverloads constructor( } fun bind(model: ChipModel) { + if (this.model == model) { + return + } this.model = model if (model.titleResId == 0) { @@ -127,13 +147,7 @@ class ChipsView @JvmOverloads constructor( isChecked = false isCheckable = false } - if (model.icon == 0 || model.isChecked) { - chipIcon = null - isChipIconVisible = false - } else { - setChipIconResource(model.icon) - isChipIconVisible = true - } + bindIcon(model) isCheckedIconVisible = model.isChecked isCloseIconVisible = if (model.isCloseable || model.isDropdown) { setCloseIconResource( @@ -147,6 +161,54 @@ class ChipsView @JvmOverloads constructor( } override fun toggle() = Unit + + private fun bindIcon(model: ChipModel) { + when { + model.isChecked -> { + imageRequest?.dispose() + imageRequest = null + chipIcon = null + isChipIconVisible = false + } + + model.isLoading -> { + imageRequest?.dispose() + imageRequest = null + isChipIconVisible = true + setProgressIcon() + } + + model.iconData != null -> { + val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon } + imageRequest = ImageRequest.Builder(context) + .data(model.iconData) + .crossfade(false) + .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) + .target(ChipIconTarget(this)) + .placeholder(placeholder) + .fallback(placeholder) + .error(placeholder) + .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) + .allowRgb565(true) + .enqueueWith(coil) + isChipIconVisible = true + } + + model.icon != 0 -> { + imageRequest?.dispose() + imageRequest = null + setChipIconResource(model.icon) + isChipIconVisible = true + } + + else -> { + imageRequest?.dispose() + imageRequest = null + chipIcon = null + isChipIconVisible = false + } + } + } } private inner class InternalChipClickListener : OnClickListener { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 92d77b6a1..1b4917bd4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context +import android.database.DatabaseUtils import androidx.annotation.FloatRange import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ellipsize @@ -64,3 +65,11 @@ fun Collection.joinToStringWithLimit(context: Context, limit: Int, transf } } } + +@Deprecated("", + ReplaceWith( + "sqlEscapeString(this)", + "android.database.DatabaseUtils.sqlEscapeString" + ) +) +fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index fe62f0334..8e0443d6d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.favourites.data +import android.database.DatabaseUtils.sqlEscapeString import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -120,6 +121,12 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") abstract suspend fun findCategoriesCount(mangaId: Long): Int + @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") + abstract suspend fun findPopularSources(limit: Int): List + + @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") + abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List + /** INSERT **/ @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -200,6 +207,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)" + is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}" else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavoritesListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavoritesListQuickFilter.kt index d2de06707..24167296c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavoritesListQuickFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavoritesListQuickFilter.kt @@ -1,12 +1,15 @@ package org.koitharu.kotatsu.favourites.domain +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter -import javax.inject.Inject -class FavoritesListQuickFilter @Inject constructor( +class FavoritesListQuickFilter @AssistedInject constructor( + @Assisted private val categoryId: Long, private val settings: AppSettings, private val repository: FavouritesRepository, networkState: NetworkState, @@ -22,5 +25,14 @@ class FavoritesListQuickFilter @Inject constructor( add(ListFilterOption.Macro.NEW_CHAPTERS) } add(ListFilterOption.Macro.COMPLETED) + repository.findPopularSources(categoryId, 3).mapTo(this) { + ListFilterOption.Source(it) + } + } + + @AssistedFactory + interface Factory { + + fun create(categoryId: Long): FavoritesListQuickFilter } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 1e0d91693..eed5c14e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -11,6 +11,8 @@ 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.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity @@ -22,6 +24,7 @@ import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import javax.inject.Inject @Reusable @@ -136,6 +139,16 @@ class FavouritesRepository @Inject constructor( return db.getFavouritesDao().findCategoriesIds(mangaId).toSet() } + suspend fun findPopularSources(categoryId: Long, limit: Int): List { + return db.getFavouritesDao().run { + if (categoryId == 0L) { + findPopularSources(limit) + } else { + findPopularSources(categoryId, limit) + } + }.toMangaSources() + } + suspend fun createCategory( title: String, sortOrder: ListSortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt index 60872f802..28039b358 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt @@ -6,11 +6,13 @@ import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.view.View import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getThemeColor @@ -64,14 +66,20 @@ fun categoriesHeaderAD( repeat(coverViews.size) { i -> val cover = item.covers.getOrNull(i) - coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(fallback) - source(cover?.mangaSource) - crossfade(crossFadeDuration * (i + 1)) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - enqueueWith(coil) + val view = coverViews[i] + view.isVisible = cover != null + if (cover == null) { + view.disposeImageRequest() + } else { + view.newImageRequest(lifecycleOwner, cover.url)?.run { + placeholder(R.drawable.ic_placeholder) + fallback(fallback) + source(cover.mangaSource) + crossfade(crossFadeDuration * (i + 1)) + error(R.drawable.ic_error_placeholder) + allowRgb565(true) + enqueueWith(coil) + } } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index 18863085e..fd559caa3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -49,12 +49,13 @@ class FavouritesListViewModel @Inject constructor( private val repository: FavouritesRepository, private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, - private val quickFilter: FavoritesListQuickFilter, + quickFilterFactory: FavoritesListQuickFilter.Factory, settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter { +) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener { val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID + private val quickFilter = quickFilterFactory.create(categoryId) private val refreshTrigger = MutableStateFlow(Any()) private val limit = MutableStateFlow(PAGE_SIZE) private val isPaginationReady = AtomicBoolean(false) @@ -91,6 +92,12 @@ class FavouritesListViewModel @Inject constructor( override fun onRetry() = Unit + override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) = quickFilter.setFilterOption(option, isApplied) + + override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option) + + override fun clearFilter() = quickFilter.clearFilter() + fun markAsRead(items: Set) { launchLoadingJob(Dispatchers.Default) { markAsReadUseCase(items) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 0bdbae1f4..248760075 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -66,7 +66,7 @@ class FilterCoordinator @Inject constructor( get() = repository.source val isFilterApplied: Boolean - get() = !currentListFilter.value.isEmpty() + get() = currentListFilter.value.isNotEmpty() val query: StateFlow = currentListFilter.map { it.query } .stateIn(coroutineScope, SharingStarted.Eagerly, null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index 23e0db88e..c0c9b9bd0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.history.data +import android.database.DatabaseUtils.sqlEscapeString import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -73,6 +74,9 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { ) abstract suspend fun findPopularTags(limit: Int): List + @Query("SELECT manga.source AS count FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") + abstract suspend fun findPopularSources(limit: Int): List + @Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0") abstract suspend fun find(id: Long): HistoryEntity? @@ -160,6 +164,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)" + is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}" else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 6e323bd32..6ffe83458 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -12,10 +12,13 @@ import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTags +import org.koitharu.kotatsu.core.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode @@ -26,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble @@ -177,7 +181,11 @@ class HistoryRepository @Inject constructor( } suspend fun getPopularTags(limit: Int): List { - return db.getHistoryDao().findPopularTags(limit).map { x -> x.toMangaTag() } + return db.getHistoryDao().findPopularTags(limit).toMangaTagsList() + } + + suspend fun getPopularSources(limit: Int): List { + return db.getHistoryDao().findPopularSources(limit).toMangaSources() } fun shouldSkip(manga: Manga): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryListQuickFilter.kt index e05db894d..bf0ae0ccf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryListQuickFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryListQuickFilter.kt @@ -31,5 +31,8 @@ class HistoryListQuickFilter @Inject constructor( repository.getPopularTags(3).mapTo(this) { ListFilterOption.Tag(it) } + repository.getPopularSources(3).mapTo(this) { + ListFilterOption.Source(it) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt index 83b4107e4..71f6a4cf0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt @@ -5,6 +5,12 @@ import androidx.annotation.StringRes import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.model.unwrap +import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource +import org.koitharu.kotatsu.core.parser.favicon.faviconUri +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag sealed interface ListFilterOption { @@ -19,6 +25,8 @@ sealed interface ListFilterOption { val groupKey: String + fun getIconData(): Any? = null + data object Downloaded : ListFilterOption { override val titleResId: Int @@ -88,6 +96,31 @@ sealed interface ListFilterOption { get() = "_favcat" } + data class Source( + val mangaSource: MangaSource + ) : ListFilterOption { + override val titleResId: Int + get() = when (mangaSource.unwrap()) { + is ExternalMangaSource -> R.string.external_source + LocalMangaSource -> R.string.local_storage + else -> 0 + } + + override val iconResId: Int + get() = R.drawable.ic_web + + override val titleText: CharSequence? + get() = when (val source = mangaSource.unwrap()) { + is MangaParserSource -> source.title + else -> null + } + + override val groupKey: String + get() = "_source" + + override fun getIconData() = mangaSource.faviconUri() + } + data class Inverted( val option: ListFilterOption, override val iconResId: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt index 9b224cb2f..8568c4c20 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt @@ -55,6 +55,7 @@ abstract class MangaListQuickFilter( title = option.titleText, titleResId = option.titleResId, icon = option.iconResId, + iconData = option.getIconData(), isChecked = option in selectedOptions, data = option, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 9184b0f8a..e7d48b33b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -251,10 +251,6 @@ abstract class MangaListFragment : resolveException(error) } - override fun onUpdateFilter(tags: Set) { - viewModel.onUpdateFilter(tags) - } - private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index dae93587b..5278dbf56 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -43,8 +43,6 @@ abstract class MangaListViewModel( val isIncognitoModeEnabled: Boolean get() = settings.isIncognitoModeEnabled - open fun onUpdateFilter(tags: Set) = Unit - abstract fun onRefresh() abstract fun onRetry() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt index 609126836..6cd00704d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt @@ -2,12 +2,9 @@ package org.koitharu.kotatsu.list.ui.adapter import android.view.View import org.koitharu.kotatsu.core.ui.widgets.TipView -import org.koitharu.kotatsu.parsers.model.MangaTag interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener, TipView.OnButtonClickListener, QuickFilterClickListener { - fun onUpdateFilter(tags: Set) - fun onFilterClick(view: View?) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt index 3608e550d..72c78c0ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.annotation.StringRes import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -@Suppress("DataClassPrivateConstructor") +@ExposedCopyVisibility data class ListHeader private constructor( private val textRaw: Any, @StringRes val buttonTextRes: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt index 9a9c08f43..f954f78b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -7,6 +7,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -30,6 +32,7 @@ class LocalMangaIndex @Inject constructor( ) : FlowCollector { private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() private var previousHash: Long get() = prefs.getLong(KEY_HASH, 0L) @@ -41,7 +44,7 @@ class LocalMangaIndex @Inject constructor( } } - suspend fun update(): Boolean { + suspend fun update(): Boolean = mutex.withLock { val newHash = computeHash() if (newHash == previousHash) { return false @@ -57,7 +60,13 @@ class LocalMangaIndex @Inject constructor( } suspend fun get(mangaId: Long): LocalManga? { - val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null + var path = db.getLocalMangaIndexDao().findPath(mangaId) + if (path == null && mutex.isLocked) { // wait for updating complete + path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) } + } + if (path == null) { + return null + } return runCatchingCancellable { LocalMangaInput.of(File(path)).getManga() }.onFailure { @@ -65,9 +74,11 @@ class LocalMangaIndex @Inject constructor( }.getOrNull() } - suspend fun put(manga: LocalManga) = db.withTransaction { - mangaDataRepository.storeManga(manga.manga) - db.getLocalMangaIndexDao().upsert(manga.toEntity()) + suspend fun put(manga: LocalManga) = mutex.withLock { + db.withTransaction { + mangaDataRepository.storeManga(manga.manga) + db.getLocalMangaIndexDao().upsert(manga.toEntity()) + } } suspend fun delete(mangaId: Long) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 08217f7fa..5c60c1bf9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -108,7 +108,7 @@ class LocalListViewModel @Inject constructor( } override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) { - super.createEmptyState(canResetFilter) + super.createEmptyState(true) } else { EmptyState( icon = R.drawable.ic_empty_local, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 5776e1444..19f3aed74 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -66,21 +66,32 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onEmptyActionClick() { - viewModel.resetFilter() + if (filterCoordinator.isFilterApplied) { + filterCoordinator.reset() + } else { + openInBrowser() + } } override fun onSecondaryErrorActionClick(error: Throwable) { - viewModel.browserUrl?.also { url -> + openInBrowser() + } + + private fun openInBrowser() { + val browserUrl = viewModel.browserUrl + if (browserUrl.isNullOrEmpty()) { + Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) + .show() + } else { startActivity( BrowserActivity.newIntent( requireContext(), - url, + browserUrl, viewModel.source, viewModel.source.getTitle(requireContext()), ), ) - } ?: Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) - .show() + } } private inner class RemoteListMenuProvider : MenuProvider { @@ -106,7 +117,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { } R.id.action_filter_reset -> { - viewModel.resetFilter() + filterCoordinator.reset() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 3727776b1..c3b948f18 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -41,8 +41,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaTag import javax.inject.Inject private const val FILTER_MIN_INTERVAL = 250L @@ -51,7 +49,7 @@ private const val FILTER_MIN_INTERVAL = 250L open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - override val filterCoordinator: FilterCoordinator, + final override val filterCoordinator: FilterCoordinator, settings: AppSettings, mangaListMapper: MangaListMapper, downloadScheduler: DownloadWorker.Scheduler, @@ -132,12 +130,6 @@ open class RemoteListViewModel @Inject constructor( } } - fun resetFilter() = filterCoordinator.reset() - - override fun onUpdateFilter(tags: Set) { - filterCoordinator.set(MangaListFilter(tags = tags)) - } - protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job { loadingJob?.let { if (it.isActive) return it @@ -178,7 +170,7 @@ open class RemoteListViewModel @Inject constructor( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, - actionStringRes = if (canResetFilter) R.string.reset_filter else 0, + actionStringRes = if (canResetFilter) R.string.reset_filter else R.string.open_in_browser, ) protected open suspend fun onBuildList(list: MutableList) = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index a719c4f20..f4975fa04 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -142,8 +142,6 @@ class MultiSearchActivity : override fun onFilterOptionClick(option: ListFilterOption) = Unit - override fun onUpdateFilter(tags: Set) = Unit - override fun onFilterClick(view: View?) = Unit override fun onEmptyActionClick() = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt index 317f2ffff..f61d88f3f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.suggestions.data +import android.database.DatabaseUtils.sqlEscapeString import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -48,6 +49,9 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT tags.* FROM suggestions LEFT JOIN tags ON (tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id)) GROUP BY tag_id ORDER BY COUNT(tags.tag_id) DESC LIMIT :limit") abstract suspend fun getTopTags(limit: Int): List + @Query("SELECT manga.source AS count FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") + abstract suspend fun getTopSources(limit: Int): List + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: SuggestionEntity): Long @@ -71,6 +75,7 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})" + is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}" else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index 7de9f3482..b7f8a4e6a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -8,9 +8,11 @@ import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTagsList +import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import javax.inject.Inject @@ -56,6 +58,11 @@ class SuggestionRepository @Inject constructor( .toMangaTagsList() } + suspend fun getTopSources(limit: Int): List { + return db.getSuggestionDao().getTopSources(limit) + .toMangaSources() + } + suspend fun replace(suggestions: Iterable) { db.withTransaction { db.getSuggestionDao().deleteAll() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionsListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionsListQuickFilter.kt index 78b7ea86c..18c69deb6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionsListQuickFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionsListQuickFilter.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.suggestions.domain -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.MangaListQuickFilter @@ -19,5 +18,8 @@ class SuggestionsListQuickFilter @Inject constructor( add(ListFilterOption.Macro.NSFW) add(ListFilterOption.SFW) } + suggestionRepository.getTopSources(3).mapTo(this) { + ListFilterOption.Source(it) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 935bbe0db..4643610f1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -22,7 +22,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.databinding.FragmentFeedBinding +import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -39,7 +39,7 @@ import javax.inject.Inject @AndroidEntryPoint class FeedFragment : - BaseFragment(), + BaseFragment(), PaginationScrollListener.Callback, MangaListListener, SwipeRefreshLayout.OnRefreshListener { @@ -53,9 +53,9 @@ class FeedFragment : override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, - ) = FragmentFeedBinding.inflate(inflater, container, false) + ) = FragmentListBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: FragmentFeedBinding, savedInstanceState: Bundle?) { + override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)) feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v -> @@ -99,8 +99,6 @@ class FeedFragment : override fun onRetryClick(error: Throwable) = Unit - override fun onUpdateFilter(tags: Set) = Unit - override fun onFilterClick(view: View?) = Unit override fun onEmptyActionClick() = Unit diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml deleted file mode 100644 index 54bbad473..000000000 --- a/app/src/main/res/layout/fragment_feed.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/item_categories_header.xml b/app/src/main/res/layout/item_categories_header.xml index dc176e98a..97990dd41 100644 --- a/app/src/main/res/layout/item_categories_header.xml +++ b/app/src/main/res/layout/item_categories_header.xml @@ -58,25 +58,33 @@ android:background="?attr/colorSecondaryContainer" android:backgroundTintMode="src_atop" android:scaleType="centerCrop" - app:layout_constraintBottom_toBottomOf="@id/guideline" + app:layout_constraintBottom_toBottomOf="@id/imageView_cover2" app:layout_constraintDimensionRatio="W,13:18" app:layout_constraintStart_toStartOf="@id/guideline_start" - app:layout_constraintTop_toTopOf="parent" + app:layout_constraintTop_toTopOf="@id/imageView_cover2" + app:layout_goneMarginTop="0dp" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small" tools:src="@tools:sample/backgrounds/scenic" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f78da58e..c49fe7fd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,7 +325,7 @@ Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers. Show recent manga shortcuts Make recent manga available by long pressing on application icon - Tapping on the right edge, or pressing the right key, always switches to the next page. + Do not adjust the page switching direction to the reader mode, e. g. pressing the right key always switches to the next page. This option affects only hardware input devices Ergonomic reader control Color correction Brightness From 90226b7b786fce8e2fd98fdac6b8fd948bb60628 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 23 Sep 2024 18:28:55 +0300 Subject: [PATCH 18/71] Update supported domains --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1898 +++++++++++++++++------------- 2 files changed, 1087 insertions(+), 813 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0daab315f..2655e8c83 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:f410df40f1') { + implementation('com.github.KotatsuApp:kotatsu-parsers:1.0') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b315e91c6..e26066b48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -406,62 +406,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -469,359 +414,45 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + @@ -831,485 +462,1128 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - + + + + + + + + + - - - - - + + - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - + + - - - - - - - - - + - - - - - - - - - + - - - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0ee1cda0e474592344dc88aaf0a93aa1957d785c Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 23 Sep 2024 19:22:48 +0300 Subject: [PATCH 19/71] Local manga source filter --- .../kotatsu/filter/ui/FilterFieldLayout.kt | 2 +- .../filter/ui/tags/TagsCatalogAdapter.kt | 2 ++ .../list/ui/adapter/ErrorStateListAD.kt | 20 +++++++++-------- .../local/data/LocalMangaRepository.kt | 22 +++++++++++++++---- .../local/data/index/LocalMangaIndex.kt | 5 +++++ .../local/data/index/LocalMangaIndexDao.kt | 4 +++- .../kotatsu/local/domain/model/LocalManga.kt | 17 ++++++++------ 7 files changed, 50 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt index 107f66072..90ca05cd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt @@ -97,7 +97,7 @@ class FilterFieldLayout @JvmOverloads constructor( label, context.getThemeColorStateList(materialR.attr.colorControlNormal), ) - addView(errorView) + addView(label) errorView = label return label } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt index 1fad15e5b..0dd728c61 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt @@ -11,6 +11,7 @@ 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.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel @@ -24,6 +25,7 @@ class TagsCatalogAdapter( addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null)) + addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null)) } override fun getSectionText(context: Context, position: Int): CharSequence? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt index 89f9741ab..0bc648675 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt @@ -11,20 +11,22 @@ import org.koitharu.kotatsu.list.ui.model.ErrorState import org.koitharu.kotatsu.list.ui.model.ListModel fun errorStateListAD( - listener: ListStateHolderListener, + listener: ListStateHolderListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemErrorStateBinding.inflate(inflater, parent, false) }, ) { - val onClickListener = View.OnClickListener { v -> - when (v.id) { - R.id.button_retry -> listener.onRetryClick(item.exception) - R.id.button_secondary -> listener.onSecondaryErrorActionClick(item.exception) + if (listener != null) { + val onClickListener = View.OnClickListener { v -> + when (v.id) { + R.id.button_retry -> listener.onRetryClick(item.exception) + R.id.button_secondary -> listener.onSecondaryErrorActionClick(item.exception) + } } - } - binding.buttonRetry.setOnClickListener(onClickListener) - binding.buttonSecondary.setOnClickListener(onClickListener) + binding.buttonRetry.setOnClickListener(onClickListener) + binding.buttonSecondary.setOnClickListener(onClickListener) + } bind { with(binding.textViewError) { @@ -32,7 +34,7 @@ fun errorStateListAD( setCompoundDrawablesWithIntrinsicBounds(0, item.icon, 0, 0) } with(binding.buttonRetry) { - isVisible = item.canRetry + isVisible = item.canRetry && listener != null setText(item.buttonText) } binding.buttonSecondary.setTextAndVisible(item.secondaryButtonText) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index d43bf2805..c27deb7ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -25,13 +25,16 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaUtil import org.koitharu.kotatsu.local.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet @@ -67,7 +70,14 @@ class LocalMangaRepository @Inject constructor( settings.localListOrder = value } - override suspend fun getFilterOptions() = MangaListFilterOptions() + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = localMangaIndex.getAvailableTags().mapToSet { MangaTag(title = it, key = it, source = source) }, + availableContentRating = if (!settings.isNsfwContentDisabled) { + EnumSet.of(ContentRating.SAFE, ContentRating.ADULT) + } else { + emptySet() + }, + ) override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { if (offset > 0) { @@ -75,7 +85,7 @@ class LocalMangaRepository @Inject constructor( } val list = getRawList() if (settings.isNsfwContentDisabled) { - list.removeIf { it.manga.isNsfw } + list.removeAll { it.manga.isNsfw } } if (filter != null) { val query = filter.query @@ -83,10 +93,14 @@ class LocalMangaRepository @Inject constructor( list.retainAll { x -> x.isMatchesQuery(query) } } if (filter.tags.isNotEmpty()) { - list.retainAll { x -> x.containsTags(filter.tags) } + list.retainAll { x -> x.containsTags(filter.tags.mapToSet { it.title }) } } if (filter.tagsExclude.isNotEmpty()) { - list.removeAll { x -> x.containsAnyTag(filter.tags) } + list.removeAll { x -> x.containsAnyTag(filter.tagsExclude.mapToSet { it.title }) } + } + filter.contentRating.singleOrNull()?.let { contentRating -> + val isNsfw = contentRating == ContentRating.ADULT + list.retainAll { it.manga.isNsfw == isNsfw } } } when (order) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt index f954f78b5..e68067acf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import javax.inject.Inject @@ -85,6 +86,10 @@ class LocalMangaIndex @Inject constructor( db.getLocalMangaIndexDao().delete(mangaId) } + suspend fun getAvailableTags(): List { + return db.getLocalMangaIndexDao().findTags() + } + private fun LocalManga.toEntity() = LocalMangaIndexEntity( mangaId = manga.id, path = file.path, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt index 909a0458c..8b1b6f1b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.local.data.index import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert -import org.koitharu.kotatsu.core.db.entity.TagEntity @Dao interface LocalMangaIndexDao { @@ -11,6 +10,9 @@ interface LocalMangaIndexDao { @Query("SELECT path FROM local_index WHERE manga_id = :mangaId") suspend fun findPath(mangaId: Long): String? + @Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE title IS NOT NULL GROUP BY title") + suspend fun findTags(): List + @Upsert suspend fun upsert(entity: LocalMangaIndexEntity) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt index 1f23d304f..2d8e8941e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt @@ -23,17 +23,20 @@ data class LocalManga( fun isMatchesQuery(query: String): Boolean { return manga.title.contains(query, ignoreCase = true) || - manga.altTitle?.contains(query, ignoreCase = true) == true + manga.altTitle?.contains(query, ignoreCase = true) == true || + manga.author?.contains(query, ignoreCase = true) == true } - fun containsTags(tags: Set): Boolean { - return manga.tags.containsAll(tags) + fun containsTags(tags: Collection): Boolean { + return tags.all { tag -> tag in manga.tags } } - fun containsAnyTag(tags: Set): Boolean { - return tags.any { tag -> - manga.tags.contains(tag) - } + fun containsAnyTag(tags: Collection): Boolean { + return tags.any { tag -> tag in manga.tags } + } + + private operator fun Collection.contains(title: String): Boolean { + return any { it.title.equals(title, ignoreCase = true) } } override fun toString(): String { From 2191d9c83b14d8ee57794063a8c778270679b565 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 24 Sep 2024 10:18:10 +0300 Subject: [PATCH 20/71] Fix sources catalog content types --- app/build.gradle | 6 +- .../org/koitharu/kotatsu/core/model/Manga.kt | 1 + .../kotatsu/core/model/MangaSource.kt | 1 + .../kotatsu/core/util/ext/Throwable.kt | 2 +- .../kotatsu/filter/ui/FilterHeaderProducer.kt | 65 ++++++++++--------- .../sources/catalog/SourcesCatalogActivity.kt | 14 ++-- .../catalog/SourcesCatalogViewModel.kt | 23 ++++++- app/src/main/res/values/strings.xml | 2 + 8 files changed, 70 insertions(+), 44 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2655e8c83..1f6ae40ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 670 - versionName = '7.6-a2' + versionCode = 671 + versionName = '7.6-a3' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:1.0') { + implementation('com.github.KotatsuApp:kotatsu-parsers:613623fa53') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index 78c4bd3da..74bcb3a30 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -80,6 +80,7 @@ val Demographic.titleResId: Int Demographic.SHOUJO -> R.string.demographic_shoujo Demographic.SEINEN -> R.string.demographic_seinen Demographic.JOSEI -> R.string.demographic_josei + Demographic.KODOMO -> R.string.demographic_kodomo Demographic.NONE -> R.string.none } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 84c12973a..9c8ff510d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -61,6 +61,7 @@ val ContentType.titleResId ContentType.MANHWA -> R.string.content_type_manhwa ContentType.MANHUA -> R.string.content_type_manhua ContentType.NOVEL -> R.string.content_type_novel + ContentType.ONE_SHOT -> R.string.content_type_one_shot } tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index c193e01c9..060703b77 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -89,7 +89,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) - else -> getDisplayMessage(message, resources) ?: localizedMessage + else -> getDisplayMessage(message, resources) ?: message }.ifNullOrEmpty { resources.getString(R.string.error_occurred) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index f43811745..9b6ef87c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.MangaSearchRepository @@ -19,6 +20,7 @@ class FilterHeaderProducer @Inject constructor( return combine(filterCoordinator.tags, filterCoordinator.query) { tags, query -> createChipsList( source = filterCoordinator.mangaSource, + capabilities = filterCoordinator.capabilities, property = tags, query = query, limit = 8, @@ -34,42 +36,45 @@ class FilterHeaderProducer @Inject constructor( private suspend fun createChipsList( source: MangaSource, + capabilities: MangaListFilterCapabilities, property: FilterProperty, query: String?, limit: Int, ): List { - val selectedTags = property.selectedItems.toMutableSet() - var tags = if (selectedTags.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, source) - } else { - searchRepository.getTagsSuggestion(selectedTags).take(limit) - } - if (tags.size < limit) { - tags = tags + property.availableItems.take(limit - tags.size) - } - if (tags.isEmpty() && selectedTags.isEmpty()) { - return emptyList() - } - val result = ArrayDeque(tags.size + selectedTags.size + 1) - for (tag in tags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = selectedTags.remove(tag), - data = tag, - ) - if (model.isChecked) { - result.addFirst(model) + val result = ArrayDeque(limit + 3) + if (query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { + val selectedTags = property.selectedItems.toMutableSet() + var tags = if (selectedTags.isEmpty()) { + searchRepository.getTagsSuggestion("", limit, source) } else { - result.addLast(model) + searchRepository.getTagsSuggestion(selectedTags).take(limit) + } + if (tags.size < limit) { + tags = tags + property.availableItems.take(limit - tags.size) + } + if (tags.isEmpty() && selectedTags.isEmpty()) { + return emptyList() + } + for (tag in tags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = selectedTags.remove(tag), + data = tag, + ) + if (model.isChecked) { + result.addFirst(model) + } else { + result.addLast(model) + } + } + for (tag in selectedTags) { + val model = ChipsView.ChipModel( + title = tag.title, + isChecked = true, + data = tag, + ) + result.addFirst(model) } - } - for (tag in selectedTags) { - val model = ChipsView.ChipModel( - title = tag.title, - isChecked = true, - data = tag, - ) - result.addFirst(model) } if (!query.isNullOrEmpty()) { result.addFirst( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index a655cb410..543a4e707 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -64,8 +64,8 @@ class SourcesCatalogActivity : BaseActivity(), this, ReversibleActionObserver(viewBinding.recyclerView), ) - combine(viewModel.appliedFilter, viewModel.hasNewSources, ::Pair).observe(this) { - updateFilers(it.first, it.second) + combine(viewModel.appliedFilter, viewModel.hasNewSources, viewModel.contentTypes, ::Triple).observe(this) { + updateFilers(it.first, it.second, it.third) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } @@ -111,8 +111,9 @@ class SourcesCatalogActivity : BaseActivity(), private fun updateFilers( appliedFilter: SourcesCatalogFilter, hasNewSources: Boolean, + contentTypes: List, ) { - val chips = ArrayList(ContentType.entries.size + 2) + val chips = ArrayList(contentTypes.size + 2) chips += ChipModel( title = appliedFilter.locale?.toLocale().getDisplayName(this), icon = R.drawable.ic_language, @@ -126,11 +127,8 @@ class SourcesCatalogActivity : BaseActivity(), data = true, ) } - for (type in ContentType.entries) { - if (type == ContentType.HENTAI && viewModel.isNsfwDisabled) { - continue - } - chips += ChipModel( + contentTypes.mapTo(chips) { type -> + ChipModel( title = getString(type.titleResId), isChecked = type in appliedFilter.types, data = type, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt index a924dfc5b..0a190cc67 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.sources.catalog +import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope import androidx.room.invalidationTrackerFlow import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,6 +14,7 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_SOURCES +import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction @@ -23,7 +25,9 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource +import java.util.EnumMap import java.util.EnumSet import java.util.Locale import javax.inject.Inject @@ -49,11 +53,11 @@ class SourcesCatalogViewModel @Inject constructor( ), ) - val isNsfwDisabled = settings.isNsfwContentDisabled - val hasNewSources = repository.observeHasNewSources() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + val contentTypes = MutableStateFlow>(emptyList()) + val content: StateFlow> = combine( searchQuery, appliedFilter, @@ -64,6 +68,9 @@ class SourcesCatalogViewModel @Inject constructor( init { repository.clearNewSourcesBadge() + launchJob(Dispatchers.Default) { + contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) + } } fun performSearch(query: String?) { @@ -129,4 +136,16 @@ class SourcesCatalogViewModel @Inject constructor( } } } + + @WorkerThread + private fun getContentTypes(isNsfwDisabled: Boolean): List { + val map = EnumMap(ContentType::class.java) + for (e in MangaParserSource.entries) { + if (isNsfwDisabled && e.isNsfw()) { + continue + } + map[e.contentType] = map.getOrDefault(e.contentType, 0) + 1 + } + return map.entries.sortedByDescending { it.value }.map { it.key } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c49fe7fd5..2076ad1f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -727,4 +727,6 @@ Years Any This source does not support search with filters. Your filters have been cleared + Kodomo + One shot From 720c389dbdade944412a7f039fa5e0fb9f4f470b Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 24 Sep 2024 11:13:09 +0300 Subject: [PATCH 21/71] Search in history, favorites and local --- app/src/main/AndroidManifest.xml | 2 +- .../kotatsu/core/db/entity/EntityMapping.kt | 2 + .../kotatsu/details/ui/DetailsMenuProvider.kt | 4 +- .../download/ui/list/DownloadsActivity.kt | 4 +- .../kotatsu/explore/ui/ExploreFragment.kt | 4 +- .../kotatsu/favourites/data/FavouritesDao.kt | 5 + .../favourites/domain/FavouritesRepository.kt | 13 +- .../kotatsu/history/data/HistoryDao.kt | 5 + .../kotatsu/history/data/HistoryRepository.kt | 9 +- .../local/data/LocalMangaRepository.kt | 11 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 4 +- .../ui/config/ScrobblerConfigActivity.kt | 2 +- ...ltiSearchActivity.kt => SearchActivity.kt} | 21 ++-- ...ListModel.kt => SearchResultsListModel.kt} | 16 ++- ...iSearchViewModel.kt => SearchViewModel.kt} | 111 +++++++++++++++++- ...MultiSearchAdapter.kt => SearchAdapter.kt} | 14 ++- .../ui/multi/adapter/SearchResultsAD.kt | 11 +- .../TrackerCategoriesConfigSheet.kt | 2 +- .../kotatsu/tracker/ui/feed/FeedFragment.kt | 19 +-- .../widget/shelf/ShelfWidgetConfigActivity.kt | 6 +- ...y_search_multi.xml => activity_search.xml} | 2 +- app/src/main/res/layout/fragment_list.xml | 1 - app/src/main/res/values/styles.xml | 2 +- 23 files changed, 198 insertions(+), 72 deletions(-) rename app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/{MultiSearchActivity.kt => SearchActivity.kt} (90%) rename app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/{MultiSearchListModel.kt => SearchResultsListModel.kt} (55%) rename app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/{MultiSearchViewModel.kt => SearchViewModel.kt} (58%) rename app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/{MultiSearchAdapter.kt => SearchAdapter.kt} (77%) rename app/src/main/res/layout/{activity_search_multi.xml => activity_search.xml} (95%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e26066b48..cd7988db3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -192,7 +192,7 @@ ) = Manga( fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) +fun Collection.toMangaList() = map { it.toManga() } + // Model to entity fun Manga.toEntity() = MangaEntity( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index c37f5813e..0cc332f30 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -23,7 +23,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet -import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity +import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet class DetailsMenuProvider( @@ -92,7 +92,7 @@ class DetailsMenuProvider( R.id.action_related -> { viewModel.manga.value?.let { - activity.startActivity(MultiSearchActivity.newIntent(activity, it.title)) + activity.startActivity(SearchActivity.newIntent(activity, it.title)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index f798498c7..42d7eb43a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -58,9 +58,7 @@ class DownloadsActivity : BaseActivity(), RecyclerScrollKeeper(this).attach() } addMenuProvider(DownloadsMenuProvider(this, viewModel)) - viewModel.items.observe(this) { - downloadsAdapter.items = it - } + viewModel.items.observe(this, downloadsAdapter) viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) val menuInvalidator = MenuInvalidator(this) viewModel.hasActiveWorks.observe(this, menuInvalidator) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 2f4ea0c24..8c71cb5d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -91,9 +91,7 @@ class ExploreFragment : checkNotNull(sourceSelectionController).attachToRecyclerView(this) } addMenuProvider(ExploreMenuProvider(binding.root.context)) - viewModel.content.observe(viewLifecycleOwner) { - exploreAdapter?.items = it - } + viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 8e0443d6d..fd8376682 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.Flow import org.intellij.lang.annotations.Language import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES +import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder @@ -32,6 +33,10 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") abstract suspend fun findLast(limit: Int): List + @Transaction + @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") + abstract suspend fun search(query: String, limit: Int): List + fun observeAll( order: ListSortOrder, filterOptions: Set, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index eed5c14e7..c65a2acce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -10,21 +10,21 @@ import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity +import org.koitharu.kotatsu.core.db.entity.toMangaList import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory -import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.levenshteinDistance import javax.inject.Inject @Reusable @@ -43,12 +43,17 @@ class FavouritesRepository @Inject constructor( return entities.toMangaList() } + suspend fun search(query: String, limit: Int): List { + val entities = db.getFavouritesDao().search("%$query%", limit) + return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + } + fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { if (ListFilterOption.Downloaded in filterOptions) { return localObserver.observeAll(order, filterOptions, limit) } return db.getFavouritesDao().observeAll(order, filterOptions, limit) - .mapItems { it.toManga() } + .map { it.toMangaList() } } suspend fun getManga(categoryId: Long): List { @@ -66,7 +71,7 @@ class FavouritesRepository @Inject constructor( return localObserver.observeAll(categoryId, order, filterOptions, limit) } return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit) - .mapItems { it.toManga() } + .map { it.toMangaList() } } fun observeAll(categoryId: Long, filterOptions: Set, limit: Int): Flow> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index c0c9b9bd0..1065ee922 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -11,6 +11,7 @@ import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_HISTORY +import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder @@ -23,6 +24,10 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") abstract suspend fun findAll(offset: Int, limit: Int): List + @Transaction + @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") + abstract suspend fun search(query: String, limit: Int): List + @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") abstract fun observeAll(): Flow> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 6ffe83458..e0879081b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -10,11 +10,10 @@ import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTag +import org.koitharu.kotatsu.core.db.entity.toMangaList import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isNsfw @@ -31,6 +30,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase @@ -52,6 +52,11 @@ class HistoryRepository @Inject constructor( return entities.map { it.manga.toManga(it.tags.toMangaTags()) } } + suspend fun search(query: String, limit: Int): List { + val entities = db.getHistoryDao().search("%$query%", limit) + return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + } + suspend fun getCount(): Int { return db.getHistoryDao().getCount() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index c27deb7ef..f2c6ae588 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -34,6 +34,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File @@ -62,7 +63,12 @@ class LocalMangaRepository @Inject constructor( isSearchWithFiltersSupported = true, ) - override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) + override val sortOrders: Set = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.RATING, + SortOrder.NEWEST, + SortOrder.RELEVANCE, + ) override var defaultSortOrder: SortOrder get() = settings.localListOrder @@ -102,6 +108,9 @@ class LocalMangaRepository @Inject constructor( val isNsfw = contentRating == ContentRating.ADULT list.retainAll { it.manga.isNsfw == isNsfw } } + if (!query.isNullOrEmpty() && order == SortOrder.RELEVANCE) { + list.sortBy { it.manga.title.levenshteinDistance(query) } + } } when (order) { SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index afd073a9b..384fbd453 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -66,7 +66,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity +import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel @@ -258,7 +258,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onQueryClick(query: String, submit: Boolean) { viewBinding.searchView.query = query if (submit && query.isNotEmpty()) { - startActivity(MultiSearchActivity.newIntent(this, query)) + startActivity(SearchActivity.newIntent(this, query)) searchSuggestionViewModel.saveQuery(query) viewBinding.searchView.post { closeSearchCallback.handleOnBackPressed() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index 07badad9b..3f4d6bafa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -54,7 +54,7 @@ class ScrobblerConfigActivity : BaseActivity(), } viewBinding.imageViewAvatar.setOnClickListener(this) - viewModel.content.observe(this, listAdapter::setItems) + viewModel.content.observe(this, listAdapter) viewModel.user.observe(this, this::onUserChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index f4975fa04..dc72807e8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -23,7 +23,7 @@ import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding +import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet @@ -38,12 +38,12 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter +import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import javax.inject.Inject @AndroidEntryPoint -class MultiSearchActivity : - BaseActivity(), +class SearchActivity : + BaseActivity(), MangaListListener, ListSelectionController.Callback { @@ -53,16 +53,15 @@ class MultiSearchActivity : @Inject lateinit var settings: AppSettings - private val viewModel by viewModels() - private lateinit var adapter: MultiSearchAdapter + private val viewModel by viewModels() private lateinit var selectionController: ListSelectionController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivitySearchMultiBinding.inflate(layoutInflater)) + setContentView(ActivitySearchBinding.inflate(layoutInflater)) title = viewModel.query - val itemCLickListener = OnListItemClickListener { item, view -> + val itemCLickListener = OnListItemClickListener { item, view -> startActivity( MangaListActivity.newIntent( view.context, @@ -79,7 +78,7 @@ class MultiSearchActivity : registryOwner = this, callback = this, ) - adapter = MultiSearchAdapter( + val adapter = SearchAdapter( lifecycleOwner = this, coil = coil, listener = this, @@ -96,7 +95,7 @@ class MultiSearchActivity : setSubtitle(R.string.search_results) } - viewModel.list.observe(this) { adapter.items = it } + viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView)) } @@ -194,7 +193,7 @@ class MultiSearchActivity : const val EXTRA_QUERY = "query" fun newIntent(context: Context, query: String) = - Intent(context, MultiSearchActivity::class.java) + Intent(context, SearchActivity::class.java) .putExtra(EXTRA_QUERY, query) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt index 317652294..ef6a4d5ee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt @@ -1,23 +1,33 @@ package org.koitharu.kotatsu.search.ui.multi +import android.content.Context +import androidx.annotation.StringRes +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.MangaSource -data class MultiSearchListModel( +data class SearchResultsListModel( + @StringRes val titleResId: Int, val source: MangaSource, val hasMore: Boolean, val list: List, val error: Throwable?, ) : ListModel { + fun getTitle(context: Context): String = if (titleResId != 0) { + context.getString(titleResId) + } else { + source.getTitle(context) + } + override fun areItemsTheSame(other: ListModel): Boolean { - return other is MultiSearchListModel && source == other.source + return other is SearchResultsListModel && source == other.source && titleResId == other.titleResId } override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is MultiSearchListModel && previousState.list != list) { + return if (previousState is SearchResultsListModel && previousState.list != list) { ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } else { super.getChangePayload(previousState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 155c9eefd..3edd17e9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -31,13 +33,17 @@ import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @@ -45,16 +51,19 @@ private const val MAX_PARALLELISM = 4 private const val MIN_HAS_MORE_ITEMS = 8 @HiltViewModel -class MultiSearchViewModel @Inject constructor( +class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, private val mangaRepositoryFactory: MangaRepository.Factory, private val downloadScheduler: DownloadWorker.Scheduler, private val sourcesRepository: MangaSourcesRepository, + private val historyRepository: HistoryRepository, + private val localMangaRepository: LocalMangaRepository, + private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val onDownloadStarted = MutableEventFlow() - val query = savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty() + val query = savedStateHandle.get(SearchActivity.EXTRA_QUERY).orEmpty() private val retryCounter = MutableStateFlow(0) private val listData = retryCounter.flatMapLatest { @@ -108,7 +117,10 @@ class MultiSearchViewModel @Inject constructor( } @CheckResult - private fun searchImpl(q: String): Flow> = channelFlow { + private fun searchImpl(q: String): Flow> = channelFlow { + searchHistory(q)?.let { send(it) } + searchFavorites(q)?.let { send(it) } + searchLocal(q)?.let { send(it) } val sources = sourcesRepository.getEnabledSources() if (sources.isEmpty()) { return@channelFlow @@ -132,12 +144,12 @@ class MultiSearchViewModel @Inject constructor( if (list.isEmpty()) { null } else { - MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list, null) + SearchResultsListModel(0, source, list.size > MIN_HAS_MORE_ITEMS, list, null) } }, onFailure = { error -> error.printStackTraceDebug() - MultiSearchListModel(source, true, emptyList(), error) + SearchResultsListModel(0, source, true, emptyList(), error) }, ) if (item != null) { @@ -146,7 +158,94 @@ class MultiSearchViewModel @Inject constructor( } } }.joinAll() - }.runningFold?>(null) { list, item -> list.orEmpty() + item } + }.runningFold?>(null) { list, item -> list.orEmpty() + item } .filterNotNull() .onEmpty { emit(emptyList()) } + + private suspend fun searchHistory(q: String): SearchResultsListModel? { + return runCatchingCancellable { + historyRepository.search(q, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { + SearchResultsListModel( + titleResId = R.string.history, + source = UnknownMangaSource, + hasMore = false, + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + error = null, + ) + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.history, + source = UnknownMangaSource, + hasMore = false, + list = emptyList(), + error = error, + ) + }, + ) + } + + private suspend fun searchFavorites(q: String): SearchResultsListModel? { + return runCatchingCancellable { + favouritesRepository.search(q, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { + SearchResultsListModel( + titleResId = R.string.favourites, + source = UnknownMangaSource, + hasMore = false, + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + error = null, + ) + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.favourites, + source = UnknownMangaSource, + hasMore = false, + list = emptyList(), + error = error, + ) + }, + ) + } + + private suspend fun searchLocal(q: String): SearchResultsListModel? { + return runCatchingCancellable { + localMangaRepository.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = q)) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { + SearchResultsListModel( + titleResId = 0, + source = LocalMangaSource, + hasMore = result.size > MIN_HAS_MORE_ITEMS, + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + error = null, + ) + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = 0, + source = LocalMangaSource, + hasMore = true, + list = emptyList(), + error = error, + ) + }, + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt index 607c525ee..1d8e62937 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.search.ui.multi.adapter +import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView.RecycledViewPool import coil.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -14,16 +16,16 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver -import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel +import org.koitharu.kotatsu.search.ui.multi.SearchResultsListModel -class MultiSearchAdapter( +class SearchAdapter( lifecycleOwner: LifecycleOwner, coil: ImageLoader, listener: MangaListListener, - itemClickListener: OnListItemClickListener, + itemClickListener: OnListItemClickListener, sizeResolver: ItemSizeResolver, selectionDecoration: MangaSelectionDecoration, -) : BaseListAdapter() { +) : BaseListAdapter(), FastScroller.SectionIndexer { init { val pool = RecycledViewPool() @@ -44,4 +46,8 @@ class MultiSearchAdapter( addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) } + + override fun getSectionText(context: Context, position: Int): CharSequence? { + return (items.getOrNull(position) as? SearchResultsListModel)?.getTitle(context) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index 9363051e8..64a45b8d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -8,7 +8,6 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration @@ -20,7 +19,7 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel +import org.koitharu.kotatsu.search.ui.multi.SearchResultsListModel fun searchResultsAD( sharedPool: RecycledViewPool, @@ -29,8 +28,8 @@ fun searchResultsAD( sizeResolver: ItemSizeResolver, selectionDecoration: MangaSelectionDecoration, listener: OnListItemClickListener, - itemClickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( + itemClickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, ) { @@ -40,13 +39,13 @@ fun searchResultsAD( ) binding.recyclerView.addItemDecoration(selectionDecoration) binding.recyclerView.adapter = adapter - val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing) + val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener) binding.buttonMore.setOnClickListener(eventListener) bind { - binding.textViewTitle.text = item.source.getTitle(context) + binding.textViewTitle.text = item.getTitle(context) binding.buttonMore.isVisible = item.hasMore adapter.items = item.list adapter.notifyDataSetChanged() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt index c9ad94181..3e0a07e34 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt @@ -31,7 +31,7 @@ class TrackerCategoriesConfigSheet : val adapter = TrackerCategoriesConfigAdapter(this) binding.recyclerView.adapter = adapter - viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } + viewModel.content.observe(viewLifecycleOwner, adapter) } override fun onItemClick(item: FavouriteCategory, view: View) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 4643610f1..9c03bbd0f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -7,6 +7,8 @@ import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.updatePadding import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil.ImageLoader import com.google.android.material.snackbar.Snackbar @@ -28,7 +30,6 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.model.Manga @@ -48,8 +49,6 @@ class FeedFragment : private val viewModel by viewModels() - private var feedAdapter: FeedAdapter? = null - override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -58,11 +57,12 @@ class FeedFragment : override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)) - feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v -> + val feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v -> viewModel.onItemClick(item) onItemClick(item.manga, v) } with(binding.recyclerView) { + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) adapter = feedAdapter setHasFixedSize(true) addOnScrollListener(PaginationScrollListener(4, this@FeedFragment)) @@ -73,17 +73,12 @@ class FeedFragment : addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) viewModel.isHeaderEnabled.drop(1).observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) - viewModel.content.observe(viewLifecycleOwner, this::onListChanged) + viewModel.content.observe(viewLifecycleOwner, feedAdapter) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { onFeedCleared() } viewModel.isRunning.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) } - override fun onDestroyView() { - feedAdapter = null - super.onDestroyView() - } - override fun onWindowInsetsChanged(insets: Insets) { val rv = requireViewBinding().recyclerView rv.updatePadding( @@ -112,10 +107,6 @@ class FeedFragment : context.startActivity(UpdatesActivity.newIntent(context)) } - private fun onListChanged(list: List) { - feedAdapter?.items = list - } - private fun onFeedCleared() { val snackbar = Snackbar.make( requireViewBinding().recyclerView, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt index aee292134..f3685c47d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt @@ -56,7 +56,7 @@ class ShelfWidgetConfigActivity : viewModel.checkedId = config.categoryId viewBinding.switchBackground.isChecked = config.hasBackground - viewModel.content.observe(this, this::onContentChanged) + viewModel.content.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } @@ -96,10 +96,6 @@ class ShelfWidgetConfigActivity : } } - private fun onContentChanged(categories: List) { - adapter.items = categories - } - private fun updateWidget() { val intent = Intent(this, ShelfWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE diff --git a/app/src/main/res/layout/activity_search_multi.xml b/app/src/main/res/layout/activity_search.xml similarity index 95% rename from app/src/main/res/layout/activity_search_multi.xml rename to app/src/main/res/layout/activity_search.xml index 7d741d592..c481d4599 100644 --- a/app/src/main/res/layout/activity_search_multi.xml +++ b/app/src/main/res/layout/activity_search.xml @@ -27,7 +27,7 @@ - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4cac8d89f..177ba4fe0 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -153,7 +153,7 @@ ?colorTertiary ?colorOnTertiary ?colorOutline - normal + small @dimen/grid_spacing_outer From addb642cc93f6c0da4bddb79445d4b0e3bf418cc Mon Sep 17 00:00:00 2001 From: Fikri Akbar Date: Mon, 23 Sep 2024 18:22:01 +0200 Subject: [PATCH 22/71] Translated using Weblate (Indonesian) Currently translated at 99.8% (688 of 689 strings) Co-authored-by: Fikri Akbar Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/ Translation: Kotatsu/Strings --- app/src/main/res/values-in/strings.xml | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index b93918abd..37900cb3c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -319,7 +319,7 @@ Pramuat konten %s - %s Tidak ada apapun di sini - Ketuk tepi kanan atau tekan tombol kanan, akan selalu beralih ke halaman selanjutnya + Mengetuk tepi kanan, atau menekan tombol kanan, akan selalu beralih ke halaman berikutnya. Sumber dinonaktifkan Tandai sebagai saat ini Tampilkan konten yang mencurigakan @@ -651,4 +651,36 @@ Sematkan Batal sematkan Sumber yang tidak disematkan + Masuk ke %s untuk melanjutkan + Fitur tidak stabil + Fungsi ini bersifat eksperimental. Pastikan Anda memiliki cadangan untuk menghindari kehilangan data + Konfigurasi proxy tidak valid + Bab-bab yang dibaca + Persen dibaca + Bab-bab yang tersisa + Eksternal/plugin + Sumber tidak disematkan + Sumber disematkan + Persen tersisa + Masuk untuk mengatur integrasi dengan %s. Ini akan memungkinkan Anda untuk melacak kemajuan dan status membaca manga Anda + Ulangi + Alamat server tidak valid + Terlalu banyak permintaan. Coba lagi setelah %s + %d d + %1$d m %2$d d + Koneksi OK + Menampilkan filter cepat + Memberikan kemampuan untuk memfilter daftar manga berdasarkan parameter tertentu + SFW + Lewati semua + Terjebak + Tidak di favorit + Plugin yang tidak kompatibel atau kesalahan internal. Pastikan Anda menggunakan versi terbaru dari plugin dan Kotatsu + Diperbarui sejak lama + Tidak populer + Rating rendah + Naik + Turun + Tanggal + Popularitas \ No newline at end of file From 66ff32e14d7db182ef2e1898b041caa97913cf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Ersen?= Date: Mon, 23 Sep 2024 18:22:01 +0200 Subject: [PATCH 23/71] Translated using Weblate (Turkish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (717 of 717 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (692 of 692 strings) Co-authored-by: Oğuz Ersen Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/ Translation: Kotatsu/Strings --- app/src/main/res/values-tr/strings.xml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 18e851c00..80399f979 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -684,4 +684,33 @@ %s ile bütünleşmeyi ayarlamak için oturum açın. Bu, manga okuma ilerlemenizi ve durumunuzu izlemenizi sağlayacaktır Kararsız özellik Bu işlev deneyseldir. Veri kaybını önlemek için lütfen yedeğiniz olduğundan emin olun + Arka plan indirmeleri + Yeni bölümleri indir + İndirilen bölümleri olan mangalar + \"%1$s\" (%2$s) mangası \"%3$s\" (%4$s) ile değiştirildi + Manga düzeltiliyor + Başarıyla düzeltildi + \"%s\" için düzeltme gerekli değil + \"%s\" için alternatif bulunamadı + Bu işlev seçilen manga için alternatif kaynaklar bulacaktır. Görev biraz zaman alacak ve arka planda çalışacaktır + Manhwa + Son eklenenler + Uzun zaman önce eklenenler + Bu saatte popüler + Bu hafta popüler + Bu ay popüler + Bu yıl popüler + Orijinal dili + Yıl + Bugün popüler + Roman + Manhua + Hedef kitle + Seinen + Shounen + Shoujo + Josei + Yıl + Hepsi + Bu kaynak filtrelerle aramayı desteklemiyor. Filtreleriniz temizlendi \ No newline at end of file From 010b1264aebe9d4d220d318c359e7981f1478668 Mon Sep 17 00:00:00 2001 From: Draken Date: Mon, 23 Sep 2024 18:22:01 +0200 Subject: [PATCH 24/71] Translated using Weblate (Vietnamese) Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (717 of 717 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (692 of 692 strings) Co-authored-by: Draken Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/ Translation: Kotatsu/Strings --- app/src/main/res/values-vi/strings.xml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 5325f86e0..0ae72648b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -684,4 +684,33 @@ Tính năng không ổn định Đăng nhập để thiết đặt với %s. Điều này sẽ cho phép bạn theo dõi tiến trình và trạng thái đọc manga của mình Tính năng này đang được thử nghiệm. Hãy chắc chắn rằng bạn đã tạo bản sao lưu để tránh việc mất dữ liệu oan + Tải khi có chương mới + Tải trong nền + Truyện đã tải sẵn chương + Đang sửa manga + Đã sửa thành công + Không tìm thấy lựa chọn thay thế cho \"%s\" + Tính năng này sẽ giúp bạn tìm và thay thế nguồn đọc cho bộ manga mà bạn chọn. Tính năng này sẽ tốn thời gian để xử lí và sẽ được xử lí ngầm (trong nền) + Manga \"%1$s\" (%2$s) đã được thay thế bằng \"%3$s\" (%4$s) + Không cần sửa đối với \"%s\" + Đã thêm gần đây + Tiểu thuyết + Manhua + Manhwa + Đã được thêm từ trước + Nổi bật trong giờ qua + Nổi bật trong tuần + Hiện đang nổi + Nổi bật trong năm + Nổi bật trong tháng + Ngôn ngữ gốc + Năm + Nhân khẩu học + Shounen + Shoujo + Seinen + Josei + Năm + Bất kì + Bộ lọc của bạn đã bị xóa do nguồn đọc này không hỗ trợ cho việc tìm kiếm bằng bộ lọc \ No newline at end of file From 0889c2cc2843e52d6e50a1dc2da17c9bff737476 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 23 Sep 2024 18:22:01 +0200 Subject: [PATCH 25/71] Translated using Weblate (Spanish) Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (717 of 717 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (692 of 692 strings) Co-authored-by: gallegonovato Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/ Translation: Kotatsu/Strings --- app/src/main/res/values-es/strings.xml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b33b307f1..03405eda6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -684,4 +684,33 @@ Inicia sesión para configurar la integración con %s . Esto te permitirá seguir tu progreso de lectura del manga Función inestable Esta función es experimental. Por favor, asegúrate de tener una copia de seguridad para evitar la pérdida de datos + Descargas en segundo plano + Descargar nuevos capítulos + Manga con capítulos descargados + Corregir el manga + No es necesario corregir \"%s\" + Manga \"%1$s\" (%2$s) sustituido con \"%3$s\" (%4$s) + Corregido correctamente + No se han encontrado alternativas para \"%s\" + Esto buscará fuentes alternativas para el manga seleccionado. La tarea tardará un tiempo y se realizará en segundo plano + Populares ahora + Populares hoy + Populares esta semana + Populares este mes + Shōnen + Shōjo + Años + Cualquiera + Novedoso + Manhua + Manhwa + Añadido hace tiempo + Añadido recientemente + Populares este año + Idioma original + Año + Demografía + Seinen + Josei + Esta fuente no admite búsquedas con filtros. Se han eliminado los filtros \ No newline at end of file From dd9df6e9dc7ce406df70b077065e864d50f3481d Mon Sep 17 00:00:00 2001 From: Amirreza Safavi Date: Mon, 23 Sep 2024 18:22:01 +0200 Subject: [PATCH 26/71] Translated using Weblate (Persian) Currently translated at 41.6% (288 of 692 strings) Co-authored-by: Amirreza Safavi Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/ Translation: Kotatsu/Strings --- app/src/main/res/values-fa/strings.xml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index c761e16bf..1eb661a19 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -247,9 +247,29 @@ مانگایی یافت نشد که با دسته‌بندی های انتخاب شده تطابق داشته باشد نشانه اضافه شد نمای مربعی - از تاریخچه پاک شد + از تاریخچه حذف شد حالت پیشفرض غیرفعال کردن بهینه سازی باتری فرستادن تلاش مجدد + برای ادامه دادن به %s وارد شوید + ویژگی ناپایدار + غیرفعال کردن کل + استفاده از اثر انگشت در صورت امکان + تشخیص خودکار وب‌تون بودن مانگا + خواندن + تکمیل شده + خواندن مجدد + مانگاهای دارای چپتر دانلود شده + دانلود چپتر های جدید + دانلودهای پس‌زمینه‌ای + این ویژگی در حالت آزمایشی می‌باشد، لطفاً از داشتن بکاپ مطمئن شوید تا داده های شما از بین نروند + بازگرداندن + خودکار + مشکلی پیش آمد. لطفاً به منظور کمک کردن به رفع مشکل، گزارشی به توسعه‌دهندگان ارسال کنید. + DNS بر روی HTTPS + تشخیص خودکار حالت خواندن + نشان دادن نشانگر پیشروی خوانش + پاک کردن داده ها + محبوبیت \ No newline at end of file From 3df268233243735ab666500c0efc639c2eef01f3 Mon Sep 17 00:00:00 2001 From: Shayan Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 27/71] Translated using Weblate (Persian) Currently translated at 41.6% (288 of 692 strings) Co-authored-by: Shayan Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/ Translation: Kotatsu/Strings --- app/src/main/res/values-fa/strings.xml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1eb661a19..5cfde0933 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -248,7 +248,7 @@ نشانه اضافه شد نمای مربعی از تاریخچه حذف شد - حالت پیشفرض + حالت پیش‌فرض غیرفعال کردن بهینه سازی باتری فرستادن تلاش مجدد @@ -272,4 +272,12 @@ نشان دادن نشانگر پیشروی خوانش پاک کردن داده ها محبوبیت + در انتظار + رها شده + مانگای نشان شده به عنوان NSFW هرگز به تاریخچه افزوده نخواهد شد و پیشرفت شما ذخیره نخواهد شد. + به بررسی‌های به‌روز رسانی‌های پس‌زمینه کمک می‌کند + مانگا از پسندیده‌های شما + گزارش + برنامه‌ریزی شده + نشان دادن درصد خوانده شده در تاریخچه و پسندیده‌ها \ No newline at end of file From 0fecf996e1332e4f635cf439ea1f5fc660e68d52 Mon Sep 17 00:00:00 2001 From: gekka <1778962971@qq.com> Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 28/71] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.7% (716 of 718 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.7% (696 of 698 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.7% (696 of 698 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.8% (691 of 692 strings) Co-authored-by: gekka <1778962971@qq.com> Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/ Translation: Kotatsu/Strings --- app/src/main/res/values-zh-rCN/strings.xml | 29 +++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 03944b444..dde42279c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -252,7 +252,7 @@ 注销 在读 重读 - 完成 + 已完结 如果可用,使用生物认证 收藏的漫画 最近阅读的漫画 @@ -684,4 +684,31 @@ 登录以连接 %s,这个操作会允许记录你的漫画阅读进度和漫画状态 不稳定特色功能 本功能为实验性功能,请确保你已经备份以防数据丢失 + 后台下载 + 下载新章节 + 正在修复漫画 + 修复成功 + 没有修复 \"%s\" 的请求 + 找不到 \"%s\" 的其他版本 + 这个功能将会从其他来源寻找你所选择的漫画,此操作耗时较长且会在后台进行 + 小说 + 国漫 + 韩漫 + 最近添加 + 很久前添加 + 今日热门 + 本周热门 + 本月热门 + 年度热门 + 原语言 + + 受众人群 + 少年漫 + 少女漫 + 青年漫 + 女性向 + + 所有 + 本图源不支持筛选搜索,已经选定的筛选已被重置。 + 来自(%2$s)源的\"%1$s\"漫画已替换成来自(%4$s)源的\"%3$s\" \ No newline at end of file From 63bfca6d3efda5b8b6204ca1eee8452f3bd362f8 Mon Sep 17 00:00:00 2001 From: Amirreza Safavi Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 29/71] Translated using Weblate (Persian) Currently translated at 41.6% (288 of 692 strings) Co-authored-by: Amirreza Safavi Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/ Translation: Kotatsu/Strings --- app/src/main/res/values-fa/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5cfde0933..97d92609e 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -274,7 +274,7 @@ محبوبیت در انتظار رها شده - مانگای نشان شده به عنوان NSFW هرگز به تاریخچه افزوده نخواهد شد و پیشرفت شما ذخیره نخواهد شد. + مانگای نشان شده به عنوان NSFWهرگز به تاریخچه افزوده نخواهد شد و پیشرفت شما ذخیره نخواهد شد. به بررسی‌های به‌روز رسانی‌های پس‌زمینه کمک می‌کند مانگا از پسندیده‌های شما گزارش From 5696ad7fa266a267b1d8e64c277ab0cb89317059 Mon Sep 17 00:00:00 2001 From: abc0922001 Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 30/71] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 92.1% (643 of 698 strings) Co-authored-by: abc0922001 Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/ Translation: Kotatsu/Strings --- app/src/main/res/values-zh-rTW/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9013aec1f..cb4524d9c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -632,4 +632,11 @@ %1$s:%2$d 固定導航介面 滾動時不隱藏導航欄和搜尋視圖 + 登入以設定與 %s 的整合。這將允許您追蹤您的漫畫閱讀進度與狀態 + 此功能為實驗性質,請確保您有備份以避免資料遺失 + 您可以停用 SSL 憑證驗證,以防在存取網路資源時遇到 SSL 相關問題。這可能會影響您的安全性。更改此設定後,需重新啟動應用程式。 + 若您遇到連線檢查問題(例如,網路已連接但應用程式進入離線模式),可以略過連線檢查 + 此功能將為選定的漫畫尋找替代來源。此任務將需要一些時間,並會在背景中進行 + 提供依照特定參數過濾漫畫列表的功能 + 不相容的外掛或內部錯誤。請確保您使用的是最新版本的外掛與 Kotatsu \ No newline at end of file From a4de58b9b3ce4350ef1e80d7f18a0e9129b3cced Mon Sep 17 00:00:00 2001 From: Infy's Tagalog Translations Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 31/71] Translated using Weblate (Filipino) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Filipino) Currently translated at 100.0% (698 of 698 strings) Co-authored-by: Infy's Tagalog Translations Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/ Translation: Kotatsu/Strings --- app/src/main/res/values-fil/strings.xml | 39 +++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index ba5646092..85d8b6796 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -15,7 +15,7 @@ Listahan Na-save I-share ang larawan - Mag-angkat + Mag-import Tanggalin Hindi suportado ang operasyong ito Pumili ng ZIP o CBZ file. @@ -24,7 +24,7 @@ Hanapin sa %s Tanggalin ang manga Permanenteng tanggalin ang \"%s\" mula sa device? - Mga setting sa pagbasa + Mga setting sa reader Magpalit ng (mga) pahina Magpatuloy I-clear ang cache ng mga thumbnail @@ -32,7 +32,7 @@ Available ang isang bagong bersyon ng app Buksan sa web browser Mga abiso - %1$d ng %2$d sa + %1$d ng %2$d ay napagana Mga bagong kabanata Subukang i-reformulate ang query. Ang iyong nabasa ay ipapakita dito @@ -68,7 +68,7 @@ Naibalik na Naghahanda… Hindi nahanap ang file - Maaari kang lumikha ng backup ng iyong kasaysayan at mga paborito at ibalik ito + Maaari kang lumikha ng backup ng iyong kasaysayan at mga paborito at i-restore ito Ngayon lang Kahapon Matagal na ang nakalipas @@ -145,7 +145,7 @@ Mga paboritong kategorya Tanggalin Parang walang laman dito… - Hanapin kung anong pwedeng basahin sa «Explore» section + Hanapin kung anong pwedeng basahin sa «Mag-Explore» na seksyon Kamakailan Mga folder ng pag-download I-save ang pahina @@ -164,10 +164,10 @@ Pagkasyahin sa lapad Itim Lumikha ng data backup - Ibalik mula sa backup + I-restore mula sa backup Naibalik ang lahat ng data Ang data ay naibalik, ngunit may mga error - Ang napiling pagsasaayos ay maaalala para sa manga na ito + Ang napiling konpigurasyon ay maaalala para sa manga na ito Isalin ang app na ito Awtorisado na Kinakailangan ang CAPTCHA @@ -211,7 +211,7 @@ Pag-synchronize Ilagay ang iyong email upang magpatuloy Itago - May mga bagong source ng manga + May mga bagong source ng manga ay available Hindi ka makakatanggap ng mga abiso ngunit ang mga bagong kabanata ay iha-highlight sa mga listahan Paganahin ang mga abiso Ayusin ang kategorya @@ -256,8 +256,8 @@ Incognito mode Walang mga kabanata Awtomatikong pag-scroll - Ka. %1$d/%2$d Pp. %3$d/%4$d - Ipakita ang information bar sa pagbasa + Kab. %1$d/%2$d Pah. %3$d/%4$d + Ipakita ang information bar sa reader Archive ng mga comics Folder na may mga larawan Nakumpleto na ang pag-import @@ -265,7 +265,7 @@ Feed Gawing magagamit ang kamakailang manga sa pamamagitan ng mahabang pagpindot sa icon ng application Ipakita ang mga kamakailang manga shortcut - Ergonomic na kontrol sa mambabasa + Ergonomic na kontrol sa reader Pagwawasto ng kulay Liwanag Kaibahan @@ -351,9 +351,9 @@ Pamantayan Webtoon Mode na pang-basa - May Mali - Pangkaloobang storage - Panlabasang storage + Mayroong Error + Internal na storage + External na storage Vibration Domain LED indicator @@ -588,7 +588,7 @@ Buwan Tatlong buwan Walang mga istatistika para sa napiling panahon - Ang manga \"%1$s\" mula sa \"%2$s\" ay papalitan ng \"%3$s\" mula sa \"%4$s\" sa iyong kasaysayan at mga paborito (kung mayroon) + Ang manga \"%1$s\" mula sa \"%2$s\" ay papalitan ng \"%3$s\" galing sa \"%4$s\" sa iyong kasaysayan at mga paborito (kung mayroon) Tanggalin ang mga binasang kabanata Walang kabanata ang natanggal Natanggal %1$s, nalinisan %2$s @@ -684,4 +684,13 @@ Mag sign in para mag set up ng integration ng %s. Ito ay magbibigay-daan sa iyo na ma-track ang iyong progress at status sa pagbabasa ng manga Hindi matatag ang katangian Ang function na ito ay pang-eksperimento. Pakitiyak na mayroon kang backup upang maiwasan ang pagkawala ng data + Mga download sa background + Mag-download ng mga bagong kabanata + Manga na may na-download na mga kabanata + Inaayos ang manga + Matagumpay na naayos + Ang function na ito ay makakahanap ng mga alternatibong source para sa napiling manga. Ang gawain ay magtatagal at magpapatuloy sa background + Ang Manga na \"%1$s\" (%2$s) ay pinalitan ng \"%3$s\" (%4$s) + Di kinakailangan ng paayos sa \"%s\" + Walang alternatibong nahanap para sa \"%s\" \ No newline at end of file From 4af6fc165ba1d9e3d04619b85863e395ccef2deb Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 32/71] Translated using Weblate (Croatian) Currently translated at 98.7% (689 of 698 strings) Co-authored-by: Milo Ivir Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/ Translation: Kotatsu/Strings --- app/src/main/res/values-hr/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 20aed729b..74f640435 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -675,4 +675,8 @@ Datum Popularnost Aktualizirano davno - + Manga „%1$s” (%2$s) zamijenjen sa „%3$s” (%4$s) + Preuzimanja u pozadini + Preuzmi nova poglavlja + Manga s preuzetim poglavljima + \ No newline at end of file From 357263b496af9755557d75dd3f6d7422f0e8b96e Mon Sep 17 00:00:00 2001 From: Anon Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 33/71] Translated using Weblate (Serbian) Currently translated at 100.0% (698 of 698 strings) Co-authored-by: Anon Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/ Translation: Kotatsu/Strings --- app/src/main/res/values-sr/strings.xml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 989d83a21..3380aab42 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -371,7 +371,7 @@ Синхронизација Пронађи поглавље Потврда изласка - Ручном избору + По прилагођеном избору Архива стрипова Више Асука @@ -566,7 +566,7 @@ Режим целог екрана Уклони из историје Локација - Затражи одредишни дииректоријум сваки пут + Затражи одредишни диркторијум сваки пут Подразумевани директоријум за чување странице Прикажи ознаке на навигационој траци Чување страница @@ -684,4 +684,13 @@ Ниска оцена Узлазно Силазно + Позадинска преузимања + Преузми нова поглавља + Манга са преузетим поглављима + Поправљање манге + Успешно поправљено + Није потребна исправка за „%s“ + Ова функција ће пронаћи алтернативне изворе за изабрану мангу. Задатак ће потрајати и наставиће се у позадини + Манга „%1$s“ (%2$s) је замењена са „%3$s“ (%4$s) + Нису пронађене алтернативе за „%s“ \ No newline at end of file From 59f4ff8a3e44c28a4eb44028a8676f53bc4edd62 Mon Sep 17 00:00:00 2001 From: desu sude Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 34/71] Translated using Weblate (Latvian) Currently translated at 24.4% (175 of 717 strings) Co-authored-by: desu sude Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/ Translation: Kotatsu/Strings --- app/src/main/res/values-lv/strings.xml | 135 ++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index a63680bf9..e1db5edac 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1,5 +1,5 @@ - + Detaļas Saraksts Kastes @@ -28,4 +28,137 @@ Pievienot Saglabāt Kopīgot + Jaunākie + Izdzēst + Iekšējā krātuve + Jaunas daļas + Noņemt + Saglabā kaut ko no interneta kataloga vai importē to no faila. + Pagriezt ekrānu + Meklēt atjauninājumus + Meklēt atjauninājumus + Jauna kategorija + Melns + Tērē mazāk enerģiju AMOLED ekrānos + Gatavojas… + Visi dati ir atjaunoti + Dati tika atjaunoti ar kļūdām + Vakar + Šodien + Izvēlētais sakārtojums tiks izmantots šajā mangā + Izveidot saīsni… + Kopīgot %s + Meklēt + Meklēt manga + Lejupielādē… + Pārstrādā… + Nosaukums + Populāri + Atjaunoti + Atdales kārtība + Filtrs + Izskats + Gaišs + Tumšs + Līdzīgi sistēmai + Ņem vai nu ZIP, vai CBZ failu. + Šī darbība nav atbalstīta + Attīrīt lapas sīkdatnes + Nav apraksta + B|kB|MB|GB|TB + Kastu izmērs + Parastais + \"Webtoon\" + Lasīšanas režīms + Iet caur lapām + Turpināt + %1$d no %2$d iespējotas + Paziņojumi + Lejupielādēt + Vibrācija + Mīļākās kategorijas + Tas, ko tu lasi, būs šeit + Ja nav ko lasīt, vari kaut ko atrast \"Meklēšana\" nodalījumā + Neeksistē manga, kas atbilstu tevis atlasītajiem filtriem + Vispirms saglabā kaut ko + Skapis + Lapas animācija + Lejupielāžu mape + Nav pieejams + Nav pieejama krātuve + Cita krātuve + Pabeigts + Visi patīkošie + Tukša ketegorija + Lasīt vēlāk + Drīz atjaunināsies + Nemeklēt + Ievadi paroli + Nepareiza parole + Pasargāt aplikāciju + Prasīt paroli, kad atver Kotatsu + Atkārto paroli + Nesakrīt paroles + Par + Nav jaunu atjauninājumu + Labi-kreisi + Ietilpt augstumā + Atkārtot + Lejupielādēts + Lejupielādes + Vērtējums + Lapas + Attīrīt + Noņemt + \"%s\" izdzēsts no vietējās krātuves + Saglabāt lapu + Saglabāta + Kopīgot attēlu + Importēt + Meklēt %sā + Izdzēst mangu + Pilnībā izdzēst \"%s\" no ierīces? + Lasīšanas iestatījumi + Kļūda + Attīrīt vāka attēlu sīkdatnes + Attīrīt meklēšanas vēsturi + Attīrīts + Ārējā krātuve + Domēns + Ir pieejama jauna aplikācijas versija + Atvērt pārlūkprogrammā + Paziņojumu iestatījumi + Paziņojuma skaņa + LED rādītājs + Šeit ir vientuļi… + Pamēģini pārformulēt meklējumu. + Nesenie + Atjauninājumi + Jaunas daļas no tā, ko tu lasi parādās šeit + Meklēšanas rezultāti + Jauna versija: %s + Izmērs: %s + Attīrīt atjauninājumus + Attīrīts + Atjaunināt + Versija %s + Centrojums + Ietilpt centrā + Ietilpt platumā + Atstāt sākumā + Dublēt un atjaunot + Dublēt datus + Atjaunot no dublējuma + Atjaunots + Fails nav atrasts + Tu vari dublēt savu vēsturi un patīokšos un tos atjaunot + Šobrīd + Aiz trejdeviņiem gadiem + Grupa + Uzspied lai mēģinātu atkal + Klusums + Vajadzīgs izpildīt CAPTCHA + Risināt + Attīrīt sīkdatnes + Visas sīkdatnes noņemtas \ No newline at end of file From 38b8966c16c5871b37165aba4ce47f107aaf52bf Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 23 Sep 2024 18:22:02 +0200 Subject: [PATCH 35/71] Translated using Weblate (Hungarian) Currently translated at 86.6% (622 of 718 strings) Translated using Weblate (Nepali) Currently translated at 32.3% (232 of 718 strings) Translated using Weblate (Hindi) Currently translated at 93.0% (668 of 718 strings) Translated using Weblate (Portuguese) Currently translated at 92.7% (666 of 718 strings) Co-authored-by: Anonymous Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/ Translation: Kotatsu/Strings --- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-ne/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 3909be026..e6c35baf6 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -664,4 +664,4 @@ शीघ्र फिल्टर दिखाएं अमान्य प्रॉक्सी विन्यास कुछ मापदंडों के आधार पर मंगा सूचियों को फिल्टर करने की क्षमता प्रदान करता है - + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ea94b4f42..c13a74792 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -618,4 +618,4 @@ Blokkold inkognitó módban Lap kivágása Előnyben részesített kiszolgáló - + \ No newline at end of file diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index cfc7ebffd..661e5b71d 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -220,4 +220,4 @@ त्रुटि विवरण:<br> <tt>%1$s</tt><br><br> 1. <a href=%2$s>वेब ब्राउजरमा मंगा खोल्ने</a> प्रयास गर्नुहोस् कि यो यसको स्रोतमा उपलब्ध छ<br> 2. निश्चित गर्नुहोस् कि तपाइँ <a href=kotatsu://about>Kotatsu को नवीनतम संस्करण</a> प्रयोग गर्दै हुनुहुन्छ<br> 3. यदि यो उपलब्ध छ भने, विकासकर्ताहरूलाई त्रुटि रिपोर्ट पठाउनुहोस्। जानरा अलग गर्नु सुझावहरू अपडेट गर्दै - + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5bae67550..5287b2e08 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -661,4 +661,4 @@ Não há mangás que correspondam aos filtros selecionados A conexão está OK Configuração de proxy inválida - + \ No newline at end of file From 8c0617c525d8bb18489f695ef4238163bc794e57 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 24 Sep 2024 16:35:44 +0300 Subject: [PATCH 36/71] Context menus --- .../bookmarks/ui/AllBookmarksFragment.kt | 15 +++-- .../bookmarks/ui/adapter/BookmarkLargeAD.kt | 5 +- .../bookmarks/ui/adapter/BookmarkListAD.kt | 5 +- .../core/ui/OnContextClickListenerCompat.kt | 8 +++ .../AdapterDelegateClickListenerAdapter.kt | 38 ++++++++++-- .../core/ui/list/ListSelectionController.kt | 62 +++++++++++++++---- .../core/ui/list/OnListItemClickListener.kt | 4 +- .../kotatsu/core/ui/util/PopupMenuMediator.kt | 6 +- .../koitharu/kotatsu/core/util/ext/View.kt | 5 +- .../kotatsu/details/ui/DetailsActivity.kt | 6 +- .../details/ui/adapter/ChapterGridItemAD.kt | 4 +- .../details/ui/adapter/ChapterListItemAD.kt | 4 +- .../ui/pager/bookmarks/BookmarksFragment.kt | 15 +++-- .../ui/pager/chapters/ChaptersFragment.kt | 8 ++- .../chapters/ChaptersSelectionCallback.kt | 21 ++++--- .../details/ui/pager/pages/PageThumbnailAD.kt | 4 +- .../details/ui/related/RelatedListFragment.kt | 12 ++-- .../download/ui/list/DownloadsActivity.kt | 27 +++++--- .../kotatsu/explore/ui/ExploreFragment.kt | 31 ++++++---- .../ui/adapter/ExploreAdapterDelegates.kt | 13 +--- .../categories/CategoriesSelectionCallback.kt | 23 ++++--- .../categories/FavouriteCategoriesActivity.kt | 6 +- .../ui/list/FavouritesListFragment.kt | 20 +++--- .../kotatsu/history/ui/HistoryListFragment.kt | 20 +++--- .../kotatsu/list/ui/MangaListFragment.kt | 31 ++++++---- .../list/ui/adapter/MangaGridItemAD.kt | 11 +--- .../ui/adapter/MangaListDetailedItemAD.kt | 12 +--- .../list/ui/adapter/MangaListItemAD.kt | 11 +--- .../kotatsu/local/ui/LocalListFragment.kt | 15 ++--- .../remotelist/ui/RemoteListFragment.kt | 11 ++-- .../ui/config/adapter/ScrobblingMangaAD.kt | 3 +- .../kotatsu/search/ui/multi/SearchActivity.kt | 23 ++++--- .../suggestions/ui/SuggestionsFragment.kt | 14 ++--- .../kotatsu/tracker/ui/feed/FeedFragment.kt | 2 + .../tracker/ui/updates/UpdatesFragment.kt | 15 +++-- 35 files changed, 320 insertions(+), 190 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/OnContextClickListenerCompat.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt index 7f2f5f762..3a0182c70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui import android.os.Bundle import android.view.LayoutInflater import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -129,7 +130,11 @@ class AllBookmarksFragment : } override fun onItemLongClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemLongClick(item.pageId) ?: false + return selectionController?.onItemLongClick(view, item.pageId) ?: false + } + + override fun onItemContextClick(item: Bookmark, view: View): Boolean { + return selectionController?.onItemContextClick(view, item.pageId) ?: false } override fun onRetryClick(error: Throwable) = Unit @@ -148,23 +153,23 @@ class AllBookmarksFragment : override fun onCreateActionMode( controller: ListSelectionController, - mode: ActionMode, + menuInflater: MenuInflater, menu: Menu, ): Boolean { - mode.menuInflater.inflate(R.menu.mode_bookmarks, menu) + menuInflater.inflate(R.menu.mode_bookmarks, menu) return true } override fun onActionItemClicked( controller: ListSelectionController, - mode: ActionMode, + mode: ActionMode?, item: MenuItem, ): Boolean { return when (item.itemId) { R.id.action_remove -> { val ids = selectionController?.snapshot() ?: return false viewModel.removeBookmarks(ids) - mode.finish() + mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt index bdbd35ef0..00c0f6a2e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt @@ -22,10 +22,7 @@ fun bookmarkLargeAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) }, ) { - val listener = AdapterDelegateClickListenerAdapter(this, clickListener) - - binding.root.setOnClickListener(listener) - binding.root.setOnLongClickListener(listener) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt index e86073cb1..065bf84dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt @@ -21,10 +21,7 @@ fun bookmarkListAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }, ) { - val listener = AdapterDelegateClickListenerAdapter(this, clickListener) - - binding.root.setOnClickListener(listener) - binding.root.setOnLongClickListener(listener) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/OnContextClickListenerCompat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/OnContextClickListenerCompat.kt new file mode 100644 index 000000000..c53d0f71a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/OnContextClickListenerCompat.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.ui + +import android.view.View + +fun interface OnContextClickListenerCompat { + + fun onContextClick(v: View): Boolean +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt index a9e6e13ea..fd8d865f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt @@ -3,18 +3,46 @@ package org.koitharu.kotatsu.core.ui.list import android.view.View import android.view.View.OnClickListener import android.view.View.OnLongClickListener +import androidx.core.util.Function import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder +import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat +import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -class AdapterDelegateClickListenerAdapter( +class AdapterDelegateClickListenerAdapter( private val adapterDelegate: AdapterDelegateViewBindingViewHolder, - private val clickListener: OnListItemClickListener, -) : OnClickListener, OnLongClickListener { + private val clickListener: OnListItemClickListener, + private val itemMapper: Function, +) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat { override fun onClick(v: View) { - clickListener.onItemClick(adapterDelegate.item, v) + clickListener.onItemClick(mappedItem(), v) } override fun onLongClick(v: View): Boolean { - return clickListener.onItemLongClick(adapterDelegate.item, v) + return clickListener.onItemLongClick(mappedItem(), v) + } + + override fun onContextClick(v: View): Boolean { + return clickListener.onItemContextClick(mappedItem(), v) + } + + private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item) + + fun attach(itemView: View) { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + itemView.setOnContextClickListenerCompat(this) + } + + companion object { + + operator fun invoke( + adapterDelegate: AdapterDelegateViewBindingViewHolder, + clickListener: OnListItemClickListener + ): AdapterDelegateClickListenerAdapter = AdapterDelegateClickListenerAdapter( + adapterDelegate = adapterDelegate, + clickListener = clickListener, + itemMapper = { x -> x }, + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt index 94ce689dc..9d834ef84 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt @@ -2,10 +2,14 @@ package org.koitharu.kotatsu.core.ui.list import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem +import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.PopupMenu import androidx.collection.LongSet +import androidx.collection.longSetOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner @@ -29,18 +33,21 @@ class ListSelectionController( ) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider { private var actionMode: ActionMode? = null + private var focusedItemId: LongSet? = null + + var useActionMode: Boolean = true val count: Int - get() = decoration.checkedItemsCount + get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount init { registryOwner.lifecycle.addObserver(StateEventObserver()) } - fun snapshot(): Set = peekCheckedIds().toSet() + fun snapshot(): Set = (focusedItemId ?: peekCheckedIds()).toSet() fun peekCheckedIds(): LongSet { - return decoration.checkedItemsIds + return focusedItemId ?: decoration.checkedItemsIds } fun clear() { @@ -52,6 +59,7 @@ class ListSelectionController( if (ids.isEmpty()) { return } + startActionMode() decoration.checkAll(ids) notifySelectionChanged() } @@ -80,15 +88,42 @@ class ListSelectionController( return false } - fun onItemLongClick(id: Long): Boolean { - return startActionMode()?.also { - decoration.setItemIsChecked(id, true) - notifySelectionChanged() - } != null + fun onItemLongClick(view: View, id: Long): Boolean { + return if (useActionMode) { + startSelection(id) + } else { + onItemContextClick(view, id) + } } + fun onItemContextClick(view: View, id: Long): Boolean { + focusedItemId = longSetOf(id) + val menu = PopupMenu(view.context, view) + callback.onCreateActionMode(this, menu.menuInflater, menu.menu) + callback.onPrepareActionMode(this, null, menu.menu) + menu.setForceShowIcon(true) + if (menu.menu.hasVisibleItems()) { + menu.setOnMenuItemClickListener { menuItem -> + callback.onActionItemClicked(this, null, menuItem) + } + menu.setOnDismissListener { + focusedItemId = null + } + menu.show() + return true + } else { + focusedItemId = null + return false + } + } + + fun startSelection(id: Long): Boolean = startActionMode()?.also { + decoration.setItemIsChecked(id, true) + notifySelectionChanged() + } != null + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - return callback.onCreateActionMode(this, mode, menu) + return callback.onCreateActionMode(this, mode.menuInflater, menu) } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { @@ -106,6 +141,7 @@ class ListSelectionController( } private fun startActionMode(): ActionMode? { + focusedItemId = null return actionMode ?: appCompatDelegate.startSupportActionMode(this).also { actionMode = it } @@ -134,14 +170,14 @@ class ListSelectionController( fun onSelectionChanged(controller: ListSelectionController, count: Int) - fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean + fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean - fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.title = controller.count.toString() + fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { + mode?.title = controller.count.toString() return true } - fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean + fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt index e394740b9..26df0cb21 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt @@ -6,5 +6,7 @@ fun interface OnListItemClickListener { fun onItemClick(item: I, view: View) - fun onItemLongClick(item: I, view: View) = false + fun onItemLongClick(item: I, view: View): Boolean = false + + fun onItemContextClick(item: I, view: View): Boolean = onItemLongClick(item, view) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PopupMenuMediator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PopupMenuMediator.kt index 558931d3a..22bbdac14 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PopupMenuMediator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/PopupMenuMediator.kt @@ -4,11 +4,15 @@ import android.view.MenuItem import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.view.MenuProvider +import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat class PopupMenuMediator( private val provider: MenuProvider, -) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { +) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener, + PopupMenu.OnDismissListener { + + override fun onContextClick(v: View): Boolean = onLongClick(v) override fun onLongClick(v: View): Boolean { val menu = PopupMenu(v.context, v) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index 82091bee9..1b0afb72a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -21,6 +21,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import com.google.android.material.tabs.TabLayout +import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat import kotlin.math.roundToInt fun View.hasGlobalPoint(x: Int, y: Int): Boolean { @@ -153,9 +154,9 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) { } } -fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) { +fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnContextClickListener(listener::onLongClick) + setOnContextClickListener(listener::onContextClick) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 6a3dfab94..951c479db 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -54,6 +54,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener @@ -114,7 +115,8 @@ class DetailsActivity : BaseActivity(), View.OnClickListener, View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener, - ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener { + ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener, + OnContextClickListenerCompat { @Inject lateinit var shortcutManager: AppShortcutManager @@ -291,6 +293,8 @@ class DetailsActivity : startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag)))) } + override fun onContextClick(v: View): Boolean = onLongClick(v) + override fun onLongClick(v: View): Boolean = when (v.id) { R.id.button_read -> { val menu = PopupMenu(v.context, v) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterGridItemAD.kt index 7ee67d92d..5cb2d5794 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterGridItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterGridItemAD.kt @@ -18,9 +18,7 @@ fun chapterGridItemAD( on = { item, _, _ -> item is ChapterListItem && item.isGrid }, ) { - val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { payloads -> if (payloads.isEmpty()) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 5be9aff8c..8d6275875 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -22,9 +22,7 @@ fun chapterListItemAD( on = { item, _, _ -> item is ChapterListItem && !item.isGrid }, ) { - val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.textViewTitle.text = item.chapter.name diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt index 8da855d10..faa8c4069 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.bookmarks import android.os.Bundle import android.view.LayoutInflater import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -133,7 +134,11 @@ class BookmarksFragment : BaseFragment(), } override fun onItemLongClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemLongClick(item.pageId) ?: false + return selectionController?.onItemLongClick(view, item.pageId) ?: false + } + + override fun onItemContextClick(item: Bookmark, view: View): Boolean { + return selectionController?.onItemContextClick(view, item.pageId) ?: false } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { @@ -142,23 +147,23 @@ class BookmarksFragment : BaseFragment(), override fun onCreateActionMode( controller: ListSelectionController, - mode: ActionMode, + menuInflater: MenuInflater, menu: Menu, ): Boolean { - mode.menuInflater.inflate(R.menu.mode_bookmarks, menu) + menuInflater.inflate(R.menu.mode_bookmarks, menu) return true } override fun onActionItemClicked( controller: ListSelectionController, - mode: ActionMode, + mode: ActionMode?, item: MenuItem, ): Boolean { return when (item.itemId) { R.id.action_remove -> { val ids = selectionController?.snapshot() ?: return false viewModel.removeBookmarks(ids) - mode.finish() + mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index 4aa3b5295..de808b1b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -116,7 +116,11 @@ class ChaptersFragment : } override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { - return selectionController?.onItemLongClick(item.chapter.id) ?: false + return selectionController?.onItemLongClick(view, item.chapter.id) ?: false + } + + override fun onItemContextClick(item: ChapterListItem, view: View): Boolean { + return selectionController?.onItemContextClick(view, item.chapter.id) ?: false } override fun onWindowInsetsChanged(insets: Insets) = Unit @@ -149,7 +153,7 @@ class ChaptersFragment : items?.indexOfFirst(predicate) ?: -1 } if (position >= 0) { - selectionController?.onItemLongClick(chapterId) + selectionController?.startSelection(chapterId) val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager) if (lm != null) { val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt index 46b9fdf78..a5cb76c3c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.details.ui.pager.chapters import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.recyclerview.widget.RecyclerView @@ -19,12 +20,16 @@ class ChaptersSelectionCallback( recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_chapters, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_chapters, menu) return true } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val selectedIds = controller.peekCheckedIds() val allItems = viewModel.chapters.value val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds } @@ -38,7 +43,7 @@ class ChaptersSelectionCallback( menu.findItem(R.id.action_delete).isVisible = canDelete menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size menu.findItem(R.id.action_mark_current).isVisible = items.size == 1 - mode.title = items.size.toString() + mode?.title = items.size.toString() var hasGap = false for (i in 0 until items.size - 1) { if (items[i].index + 1 != items[i + 1].index) { @@ -50,11 +55,11 @@ class ChaptersSelectionCallback( return true } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_save -> { viewModel.download(controller.snapshot()) - mode.finish() + mode?.finish() true } @@ -73,7 +78,7 @@ class ChaptersSelectionCallback( ).show() } } - mode.finish() + mode?.finish() true } @@ -112,7 +117,7 @@ class ChaptersSelectionCallback( } else { return false } - mode.finish() + mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt index d315fe027..832a6df67 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt @@ -32,9 +32,7 @@ fun pageThumbnailAD( height = (gridWidth / 13f * 18f).toInt(), ) - val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener) - binding.root.setOnClickListener(clickListenerAdapter) - binding.root.setOnLongClickListener(clickListenerAdapter) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt index 8ec35ac90..45cb044d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.details.ui.related import android.view.Menu -import androidx.appcompat.view.ActionMode +import android.view.MenuInflater import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -16,9 +16,13 @@ class RelatedListFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 42d7eb43a..65cd369b9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.download.ui.list import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels @@ -87,7 +88,11 @@ class DownloadsActivity : BaseActivity(), } override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { - return selectionController.onItemLongClick(item.id.mostSignificantBits) + return selectionController.onItemLongClick(view, item.id.mostSignificantBits) + } + + override fun onItemContextClick(item: DownloadItemModel, view: View): Boolean { + return selectionController.onItemContextClick(view, item.id.mostSignificantBits) } override fun onExpandClick(item: DownloadItemModel) { @@ -120,34 +125,38 @@ class DownloadsActivity : BaseActivity(), viewBinding.recyclerView.invalidateItemDecorations() } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_downloads, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_downloads, menu) return true } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_resume -> { viewModel.resume(controller.snapshot()) - mode.finish() + mode?.finish() true } R.id.action_pause -> { viewModel.pause(controller.snapshot()) - mode.finish() + mode?.finish() true } R.id.action_cancel -> { viewModel.cancel(controller.snapshot()) - mode.finish() + mode?.finish() true } R.id.action_remove -> { viewModel.remove(controller.snapshot()) - mode.finish() + mode?.finish() true } @@ -160,7 +169,7 @@ class DownloadsActivity : BaseActivity(), } } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val snapshot = viewModel.snapshot(controller.peekCheckedIds()) var canPause = true var canResume = true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 8c71cb5d5..8313db521 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -147,7 +148,11 @@ class ExploreFragment : } override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { - return sourceSelectionController?.onItemLongClick(item.id) ?: false + return sourceSelectionController?.onItemLongClick(view, item.id) ?: false + } + + override fun onItemContextClick(item: MangaSourceItem, view: View): Boolean { + return sourceSelectionController?.onItemContextClick(view, item.id) ?: false } override fun onRetryClick(error: Throwable) = Unit @@ -160,12 +165,16 @@ class ExploreFragment : viewBinding?.recyclerView?.invalidateItemDecorations() } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_source, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_source, menu) return true } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds()) val isSingleSelection = selectedSources.size == 1 menu.findItem(R.id.action_settings).isVisible = isSingleSelection @@ -177,7 +186,7 @@ class ExploreFragment : return super.onPrepareActionMode(controller, mode, menu) } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { val selectedSources = viewModel.sourcesSnapshot(controller.peekCheckedIds()) if (selectedSources.isEmpty()) { return false @@ -186,35 +195,35 @@ class ExploreFragment : R.id.action_settings -> { val source = selectedSources.singleOrNull() ?: return false startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source)) - mode.finish() + mode?.finish() } R.id.action_disable -> { viewModel.disableSources(selectedSources) - mode.finish() + mode?.finish() } R.id.action_delete -> { selectedSources.forEach { (it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) } } - mode.finish() + mode?.finish() } R.id.action_shortcut -> { val source = selectedSources.singleOrNull() ?: return false viewModel.requestPinShortcut(source) - mode.finish() + mode?.finish() } R.id.action_pin -> { viewModel.setSourcesPinned(selectedSources, isPinned = true) - mode.finish() + mode?.finish() } R.id.action_unpin -> { viewModel.setSourcesPinned(selectedSources, isPinned = false) - mode.finish() + mode?.finish() } else -> return false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index 28b93d6e5..72cb24a6e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.recyclerView -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible @@ -117,13 +116,9 @@ fun exploreSourceListItemAD( on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, ) { - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) + AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) - binding.root.setOnClickListener(eventListener) - binding.root.setOnLongClickListener(eventListener) - binding.root.setOnContextClickListenerCompat(eventListener) - bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null @@ -154,13 +149,9 @@ fun exploreSourceGridItemAD( on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, ) { - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) + AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) - binding.root.setOnClickListener(eventListener) - binding.root.setOnLongClickListener(eventListener) - binding.root.setOnContextClickListenerCompat(eventListener) - bind { binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt index 0a269669e..e8366ad69 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.recyclerview.widget.RecyclerView @@ -17,12 +18,16 @@ class CategoriesSelectionCallback( recyclerView.invalidateItemDecorations() } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_category, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_category, menu) return true } - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val categories = viewModel.getCategories(controller.peekCheckedIds()) var canShow = categories.isNotEmpty() var canHide = canShow @@ -35,11 +40,11 @@ class CategoriesSelectionCallback( } menu.findItem(R.id.action_show)?.isVisible = canShow menu.findItem(R.id.action_hide)?.isVisible = canHide - mode.title = controller.count.toString() + mode?.title = controller.count.toString() return true } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { /*R.id.action_view -> { val id = controller.peekCheckedIds().singleOrNull() ?: return false @@ -53,13 +58,13 @@ class CategoriesSelectionCallback( R.id.action_show -> { viewModel.setIsVisible(controller.snapshot(), true) - mode.finish() + mode?.finish() true } R.id.action_hide -> { viewModel.setIsVisible(controller.snapshot(), false) - mode.finish() + mode?.finish() true } @@ -72,7 +77,7 @@ class CategoriesSelectionCallback( } } - private fun confirmDeleteCategories(ids: Set, mode: ActionMode) { + private fun confirmDeleteCategories(ids: Set, mode: ActionMode?) { buildAlertDialog(recyclerView.context, isCentered = true) { setMessage(R.string.categories_delete_confirm) setTitle(R.string.remove_category) @@ -80,7 +85,7 @@ class CategoriesSelectionCallback( setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.remove) { _, _ -> viewModel.deleteCategories(ids) - mode.finish() + mode?.finish() } }.show() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index 3b029b45a..f5129ba9a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -98,7 +98,11 @@ class FavouriteCategoriesActivity : } override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean { - return item != null && selectionController.onItemLongClick(item.id) + return item != null && selectionController.onItemLongClick(view, item.id) + } + + override fun onItemContextClick(item: FavouriteCategory?, view: View): Boolean { + return item != null && selectionController.onItemContextClick(view, item.id) } override fun onSupportActionModeStarted(mode: ActionMode) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 0f632f025..474bd4159 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.list import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode @@ -52,27 +53,32 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis return true } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_favourites, menu) - return super.onCreateActionMode(controller, mode, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_favourites, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.removeFromFavourites(selectedItemsIds) - mode.finish() + mode?.finish() true } R.id.action_mark_current -> { + val itemsSnapshot = selectedItems MaterialAlertDialogBuilder(context ?: return false) .setTitle(item.title) .setMessage(R.string.mark_as_completed_prompt) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.markAsRead(selectedItems) - mode.finish() + viewModel.markAsRead(itemsSnapshot) + mode?.finish() }.show() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index da7a1d1c6..c283b4fba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.history.ui import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels @@ -34,28 +35,33 @@ class HistoryListFragment : MangaListFragment() { override fun onEmptyActionClick() = viewModel.clearFilter() - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_history, menu) - return super.onCreateActionMode(controller, mode, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_history, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.removeFromHistory(selectedItemsIds) - mode.finish() + mode?.finish() true } R.id.action_mark_current -> { + val itemsSnapshot = selectedItems buildAlertDialog(context ?: return false, isCentered = true) { setTitle(item.title) setIcon(item.icon) setMessage(R.string.mark_as_completed_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.markAsRead(selectedItems) - mode.finish() + viewModel.markAsRead(itemsSnapshot) + mode?.finish() } }.show() true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index e7d48b33b..4efd19b52 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui import android.os.Bundle import android.view.LayoutInflater import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -11,7 +12,6 @@ import androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet import androidx.core.graphics.Insets -import androidx.core.view.isNotEmpty import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager @@ -153,7 +153,11 @@ abstract class MangaListFragment : } override fun onItemLongClick(item: Manga, view: View): Boolean { - return selectionController?.onItemLongClick(item.id) ?: false + return selectionController?.onItemLongClick(view, item.id) ?: false + } + + override fun onItemContextClick(item: Manga, view: View): Boolean { + return selectionController?.onItemContextClick(view, item.id) ?: false } override fun onReadClick(manga: Manga, view: View) { @@ -280,18 +284,22 @@ abstract class MangaListFragment : } @CallSuper - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val hasNoLocal = selectedItems.none { it.isLocal } menu.findItem(R.id.action_save)?.isVisible = hasNoLocal menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal return super.onPrepareActionMode(controller, mode, menu) } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - return menu.isNotEmpty() + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + return menu.hasVisibleItems() } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_select_all -> { val ids = listAdapter?.items?.mapNotNull { @@ -303,31 +311,32 @@ abstract class MangaListFragment : R.id.action_share -> { ShareHelper(requireContext()).shareMangaLinks(selectedItems) - mode.finish() + mode?.finish() true } R.id.action_favourite -> { FavoriteSheet.show(getChildFragmentManager(), selectedItems) - mode.finish() + mode?.finish() true } R.id.action_save -> { viewModel.download(selectedItems) - mode.finish() + mode?.finish() true } R.id.action_fix -> { + val itemsSnapshot = selectedItemsIds buildAlertDialog(context ?: return false, isCentered = true) { setTitle(item.title) setIcon(item.icon) setMessage(R.string.manga_fix_prompt) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.fix) { _, _ -> - AutoFixService.start(context, selectedItemsIds) - mode.finish() + AutoFixService.start(context, itemsSnapshot) + mode?.finish() } }.show() true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index ade54bbdd..97fa7b00e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.list.ui.adapter -import android.view.View import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil.ImageLoader @@ -8,11 +7,11 @@ import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.TrimTransformation +import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED @@ -31,13 +30,7 @@ fun mangaGridItemAD( ) { var badge: BadgeDrawable? = null - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item.manga, v) - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - } - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) - itemView.setOnContextClickListenerCompat(eventListener) + AdapterDelegateClickListenerAdapter(this, clickListener, MangaGridModel::manga).attach(itemView) sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView) bind { payloads -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index 05dac38a8..272235c05 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -1,16 +1,15 @@ package org.koitharu.kotatsu.list.ui.adapter -import android.view.View import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.TrimTransformation +import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding @@ -27,14 +26,7 @@ fun mangaListDetailedItemAD( ) { var badge: BadgeDrawable? = null - val listenerAdapter = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item.manga, v) - - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - } - itemView.setOnClickListener(listenerAdapter) - itemView.setOnLongClickListener(listenerAdapter) - itemView.setOnContextClickListenerCompat(listenerAdapter) + AdapterDelegateClickListenerAdapter(this, clickListener, MangaDetailedListModel::manga).attach(itemView) bind { payloads -> binding.textViewTitle.text = item.title diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt index 8115b990b..14bf7f70b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -1,16 +1,15 @@ package org.koitharu.kotatsu.list.ui.adapter -import android.view.View import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.TrimTransformation +import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListBinding @@ -27,13 +26,7 @@ fun mangaListItemAD( ) { var badge: BadgeDrawable? = null - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item.manga, v) - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - } - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) - itemView.setOnContextClickListenerCompat(eventListener) + AdapterDelegateClickListenerAdapter(this, clickListener, MangaCompactListModel::manga).attach(itemView) bind { binding.textViewTitle.text = item.title diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 41b7307b0..d374e3360 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -4,6 +4,7 @@ import android.Manifest import android.os.Build import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.result.contract.ActivityResultContracts @@ -88,16 +89,16 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onCreateActionMode( controller: ListSelectionController, - mode: ActionMode, + menuInflater: MenuInflater, menu: Menu, ): Boolean { - mode.menuInflater.inflate(R.menu.mode_local, menu) - return super.onCreateActionMode(controller, mode, menu) + menuInflater.inflate(R.menu.mode_local, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } override fun onActionItemClicked( controller: ListSelectionController, - mode: ActionMode, + mode: ActionMode?, item: MenuItem, ): Boolean { return when (item.itemId) { @@ -109,7 +110,7 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { R.id.action_share -> { val files = selectedItems.map { it.url.toUri().toFile() } ShareHelper(requireContext()).shareCbz(files) - mode.finish() + mode?.finish() true } @@ -117,13 +118,13 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { } } - private fun showDeletionConfirm(ids: Set, mode: ActionMode) { + private fun showDeletionConfirm(ids: Set, mode: ActionMode?) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.delete_manga) .setMessage(getString(R.string.text_delete_local_manga_batch)) .setPositiveButton(R.string.delete) { _, _ -> viewModel.delete(ids) - mode.finish() + mode?.finish() } .setNegativeButton(android.R.string.cancel, null) .show() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 19f3aed74..41257db6f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -5,7 +5,6 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.appcompat.view.ActionMode import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar @@ -56,9 +55,13 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { viewModel.loadNextPage() } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } override fun onFilterClick(view: View?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt index dd6c1a1b4..68050b01f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt @@ -20,8 +20,7 @@ fun scrobblingMangaAD( { layoutInflater, parent -> ItemScrobblingMangaBinding.inflate(layoutInflater, parent, false) }, ) { - val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener) - itemView.setOnClickListener(clickListenerAdapter) + AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index dc72807e8..975dad0d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.activity.viewModels @@ -118,7 +119,11 @@ class SearchActivity : } override fun onItemLongClick(item: Manga, view: View): Boolean { - return selectionController.onItemLongClick(item.id) + return selectionController.onItemLongClick(view, item.id) + } + + override fun onItemContextClick(item: Manga, view: View): Boolean { + return selectionController.onItemContextClick(view, item.id) } override fun onReadClick(manga: Manga, view: View) { @@ -155,28 +160,32 @@ class SearchActivity : viewBinding.recyclerView.invalidateNestedItemDecorations() } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_remote, menu) return true } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_share -> { ShareHelper(this).shareMangaLinks(collectSelectedItems()) - mode.finish() + mode?.finish() true } R.id.action_favourite -> { FavoriteSheet.show(supportFragmentManager, collectSelectedItems()) - mode.finish() + mode?.finish() true } R.id.action_save -> { viewModel.download(collectSelectedItems()) - mode.finish() + mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt index 4c5bedbfe..92e192e1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -4,7 +4,6 @@ import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import androidx.appcompat.view.ActionMode import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar @@ -29,11 +28,11 @@ class SuggestionsFragment : MangaListFragment() { override fun onCreateActionMode( controller: ListSelectionController, - mode: ActionMode, + menuInflater: MenuInflater, menu: Menu, ): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) + menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } private inner class SuggestionMenuProvider : MenuProvider { @@ -71,10 +70,11 @@ class SuggestionsFragment : MangaListFragment() { companion object { @Deprecated( - "", ReplaceWith( + "", + ReplaceWith( "SuggestionsFragment()", - "org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment" - ) + "org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment", + ), ) fun newInstance() = SuggestionsFragment() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 9c03bbd0f..9ac349c60 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -62,6 +62,8 @@ class FeedFragment : onItemClick(item.manga, v) } with(binding.recyclerView) { + val paddingVertical = resources.getDimensionPixelSize(R.dimen.list_spacing_normal) + setPadding(0, paddingVertical, 0, paddingVertical) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) adapter = feedAdapter setHasFixedSize(true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt index 8fe56b9a3..01b4afb3c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.tracker.ui.updates import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels @@ -17,16 +18,20 @@ class UpdatesFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_updates, menu) - return super.onCreateActionMode(controller, mode, menu) + override fun onCreateActionMode( + controller: ListSelectionController, + menuInflater: MenuInflater, + menu: Menu + ): Boolean { + menuInflater.inflate(R.menu.mode_updates, menu) + return super.onCreateActionMode(controller, menuInflater, menu) } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { viewModel.remove(controller.snapshot()) - mode.finish() + mode?.finish() true } From 5a75fe77fdc5ab989c37032f3578a87ad5d52497 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 24 Sep 2024 17:47:35 +0300 Subject: [PATCH 37/71] Various fixes --- .../koitharu/kotatsu/core/ui/BaseViewModel.kt | 1 + .../kotatsu/core/util/ext/Collections.kt | 11 ++++ .../explore/data/MangaSourcesRepository.kt | 28 +++++----- .../domain/LocalFavoritesObserver.kt | 11 +--- .../history/data/HistoryLocalObserver.kt | 7 +-- .../local/data/index/LocalMangaIndex.kt | 56 ++++++++----------- .../local/domain/LocalObserveMapper.kt | 12 +++- .../main/ui/welcome/WelcomeViewModel.kt | 10 +++- .../catalog/SourcesCatalogViewModel.kt | 16 ++---- 9 files changed, 78 insertions(+), 74 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt index d895efa5b..d98e67739 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt @@ -65,6 +65,7 @@ abstract class BaseViewModel : ViewModel() { } protected fun Flow.withErrorHandling() = catch { error -> + error.printStackTraceDebug() errorEvent.call(error) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt index 109d605a4..8af44c883 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt @@ -97,3 +97,14 @@ fun LongSet.toSet(): Set = toCollection(ArraySet(size)) fun > LongSet.toCollection(out: R): R = out.also { result -> forEach(result::add) } + +fun Collection.mapSortedByCount(isDescending: Boolean = true, mapper: (T) -> R): List { + val grouped = groupBy(mapper).toList() + val sortSelector: (Pair>) -> Int = { it.second.size } + val sorted = if (isDescending) { + grouped.sortedByDescending(sortSelector) + } else { + grouped.sortedBy(sortSelector) + } + return sorted.map { it.first } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 2ffbd8d9c..4ccdb344f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.BuildConfig @@ -50,14 +49,13 @@ class MangaSourcesRepository @Inject constructor( private val dao: MangaSourcesDao get() = db.getSourcesDao() - private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply { - if (!BuildConfig.DEBUG) { - remove(MangaParserSource.DUMMY) - } - } - - val allMangaSources: Set - get() = Collections.unmodifiableSet(remoteSources) + val allMangaSources: Set = Collections.unmodifiableSet( + EnumSet.allOf(MangaParserSource::class.java).apply { + if (!BuildConfig.DEBUG) { + remove(MangaParserSource.DUMMY) + } + }, + ) suspend fun getEnabledSources(): List { assimilateNewSources() @@ -86,7 +84,7 @@ class MangaSourcesRepository @Inject constructor( suspend fun getDisabledSources(): Set { assimilateNewSources() - val result = EnumSet.copyOf(remoteSources) + val result = EnumSet.copyOf(allMangaSources) val enabled = dao.findAllEnabledNames() for (name in enabled) { val source = name.toMangaSourceOrNull() ?: continue @@ -182,7 +180,7 @@ class MangaSourcesRepository @Inject constructor( val result = ArrayList>(entities.size) for (entity in entities) { val source = entity.source.toMangaSourceOrNull() ?: continue - if (source in remoteSources) { + if (source in allMangaSources) { result.add(source to entity.isEnabled) } } @@ -199,7 +197,7 @@ class MangaSourcesRepository @Inject constructor( suspend fun setSourcesEnabledExclusive(sources: Set) { db.withTransaction { assimilateNewSources() - for (s in remoteSources) { + for (s in allMangaSources) { dao.setEnabled(s.name, s in sources) } } @@ -222,7 +220,7 @@ class MangaSourcesRepository @Inject constructor( fun observeHasNewSources(): Flow = observeIsNsfwDisabled().map { skipNsfw -> val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null) - sources.isNotEmpty() && sources.size != remoteSources.size + sources.isNotEmpty() && sources.size != allMangaSources.size }.onStart { assimilateNewSources() } fun observeHasNewSourcesForBadge(): Flow = combine( @@ -295,7 +293,7 @@ class MangaSourcesRepository @Inject constructor( private suspend fun getNewSources(): MutableSet { val entities = dao.findAll() - val result = EnumSet.copyOf(remoteSources) + val result = EnumSet.copyOf(allMangaSources) for (e in entities) { result.remove(e.source.toMangaSourceOrNull() ?: continue) } @@ -361,7 +359,7 @@ class MangaSourcesRepository @Inject constructor( if (skipNsfwSources && source.isNsfw()) { continue } - if (source in remoteSources) { + if (source in allMangaSources) { result.add( MangaSourceInfo( mangaSource = source, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt index 6fcacd4d2..59162a210 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.domain import dagger.Reusable import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags @@ -18,24 +17,20 @@ import javax.inject.Inject class LocalFavoritesObserver @Inject constructor( localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, -) : LocalObserveMapper(localMangaIndex, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapLatest { - it.mapToLocal() - } + ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapToLocal() fun observeAll( categoryId: Long, order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapLatest { - it.mapToLocal() - } + ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal() override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt index 00a95dc28..7deeea3c9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.history.data import dagger.Reusable -import kotlinx.coroutines.flow.mapLatest import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags @@ -17,15 +16,13 @@ import javax.inject.Inject class HistoryLocalObserver @Inject constructor( localMangaIndex: LocalMangaIndex, private val db: MangaDatabase, -) : LocalObserveMapper(localMangaIndex, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int - ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapLatest { - it.mapToLocal() - } + ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapToLocal() override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt index e68067acf..e7b6757d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -4,19 +4,15 @@ import android.content.Context import androidx.core.content.edit import androidx.room.withTransaction import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import javax.inject.Inject @@ -27,7 +23,6 @@ import javax.inject.Singleton class LocalMangaIndex @Inject constructor( private val mangaDataRepository: MangaDataRepository, private val db: MangaDatabase, - private val localStorageManager: LocalStorageManager, @ApplicationContext context: Context, private val localMangaRepositoryProvider: Provider, ) : FlowCollector { @@ -35,9 +30,9 @@ class LocalMangaIndex @Inject constructor( private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) private val mutex = Mutex() - private var previousHash: Long - get() = prefs.getLong(KEY_HASH, 0L) - set(value) = prefs.edit { putLong(KEY_HASH, value) } + private var currentVersion: Int + get() = prefs.getInt(KEY_VERSION, 0) + set(value) = prefs.edit { putInt(KEY_VERSION, value) } override suspend fun emit(value: LocalManga?) { if (value != null) { @@ -45,22 +40,25 @@ class LocalMangaIndex @Inject constructor( } } - suspend fun update(): Boolean = mutex.withLock { - val newHash = computeHash() - if (newHash == previousHash) { - return false - } + suspend fun update() = mutex.withLock { db.withTransaction { val dao = db.getLocalMangaIndexDao() dao.clear() - localMangaRepositoryProvider.get().getRawListAsFlow() - .collect { dao.upsert(it.toEntity()) } + localMangaRepositoryProvider.get() + .getRawListAsFlow() + .collect { upsert(it) } + } + currentVersion = VERSION + } + + suspend fun updateIfRequired() { + if (isUpdateRequired()) { + update() } - previousHash = newHash - return true } suspend fun get(mangaId: Long): LocalManga? { + updateIfRequired() var path = db.getLocalMangaIndexDao().findPath(mangaId) if (path == null && mutex.isLocked) { // wait for updating complete path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) } @@ -77,8 +75,7 @@ class LocalMangaIndex @Inject constructor( suspend fun put(manga: LocalManga) = mutex.withLock { db.withTransaction { - mangaDataRepository.storeManga(manga.manga) - db.getLocalMangaIndexDao().upsert(manga.toEntity()) + upsert(manga) } } @@ -90,27 +87,22 @@ class LocalMangaIndex @Inject constructor( return db.getLocalMangaIndexDao().findTags() } + private suspend fun upsert(manga: LocalManga) { + mangaDataRepository.storeManga(manga.manga) + db.getLocalMangaIndexDao().upsert(manga.toEntity()) + } + private fun LocalManga.toEntity() = LocalMangaIndexEntity( mangaId = manga.id, path = file.path, ) - private suspend fun computeHash(): Long { - return runCatchingCancellable { - localStorageManager.getReadableDirs() - .fold(0L) { acc, file -> acc + file.computeHash() } - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(0L) - } - - private suspend fun File.computeHash(): Long = runInterruptible(Dispatchers.IO) { - lastModified() // TODO size - } + private fun isUpdateRequired() = currentVersion < VERSION companion object { private const val PREF_NAME = "_local_index" - private const val KEY_HASH = "hash" + private const val KEY_VERSION = "ver" + private const val VERSION = 1 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt index 91525dd26..38fe8ed21 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt @@ -4,16 +4,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.parsers.model.Manga abstract class LocalObserveMapper( private val localMangaIndex: LocalMangaIndex, - private val limitStep: Int, ) { - protected suspend fun List.mapToLocal(): List = coroutineScope { + protected fun Flow>.mapToLocal() = onStart { + localMangaIndex.updateIfRequired() + }.mapLatest { + it.mapToLocal() + } + + private suspend fun Collection.mapToLocal(): List = coroutineScope { val dispatcher = Dispatchers.IO.limitedParallelism(6) map { item -> val m = toManga(item) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt index 350fd8c67..7ec601e12 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt @@ -9,6 +9,7 @@ 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.mapSortedByCount import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toLocale @@ -43,15 +44,20 @@ class WelcomeViewModel @Inject constructor( val types = MutableStateFlow( FilterProperty( - availableItems = ContentType.entries.toList(), + availableItems = listOf(ContentType.MANGA), selectedItems = setOf(ContentType.MANGA), - isLoading = false, + isLoading = true, error = null, ), ) init { updateJob = launchJob(Dispatchers.Default) { + val contentTypes = allSources.mapSortedByCount { it.contentType } + types.value = types.value.copy( + availableItems = contentTypes, + isLoading = false, + ) val languages = localesGroups.keys.associateBy { x -> x.language } val selectedLocales = HashSet(2) ConfigurationCompat.getLocales(context.resources.configuration).toList() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt index 0a190cc67..359e1dccd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -14,20 +14,18 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.TABLE_SOURCES -import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.mapSortedByCount import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource -import java.util.EnumMap import java.util.EnumSet import java.util.Locale import javax.inject.Inject @@ -139,13 +137,11 @@ class SourcesCatalogViewModel @Inject constructor( @WorkerThread private fun getContentTypes(isNsfwDisabled: Boolean): List { - val map = EnumMap(ContentType::class.java) - for (e in MangaParserSource.entries) { - if (isNsfwDisabled && e.isNsfw()) { - continue - } - map[e.contentType] = map.getOrDefault(e.contentType, 0) + 1 + val result = repository.allMangaSources.mapSortedByCount { it.contentType } + return if (isNsfwDisabled) { + result.filterNot { it == ContentType.HENTAI } + } else { + result } - return map.entries.sortedByDescending { it.value }.map { it.key } } } From e2cf22e054a0c201a67998355857bd6487c64adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Ersen?= Date: Tue, 24 Sep 2024 11:03:57 +0200 Subject: [PATCH 38/71] Translated using Weblate (Turkish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (717 of 717 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (692 of 692 strings) Co-authored-by: Oğuz Ersen Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/ Translation: Kotatsu/Strings --- app/src/main/res/values-tr/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 80399f979..70a3f4c08 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -314,7 +314,7 @@ İçe aktarım birazdan başlayacak Akış En son manga kısayollarını göster - Ergonomik okuyucu kontrol + Ergonomik okuyucu denetimi Renk düzeltme Parlaklık Kontrast @@ -331,7 +331,7 @@ Sunucu hatası (%1$d). Lütfen daha sonra tekrar deneyin Kaydedilen mangalar Uygulama simgesine uzun basarak son mangaları kullanılabilir hale getirin - Sağ kenara dokunulduğunda veya sağ tuşa basıldığında her zaman bir sonraki sayfaya geçilir. + Sayfa değiştirme yönü okuyucu moduna göre ayarlanmasın, örn. sağ tuşa basıldığında her zaman bir sonraki sayfaya geçilir. Bu seçenek yalnızca donanımsal giriş aygıtlarını etkiler Kaynak devre dışı İçerik ön yüklemesi Geçerli olarak işaretle From d43887e2888a3c28e7e0b367c30794abfb813b35 Mon Sep 17 00:00:00 2001 From: Draken Date: Tue, 24 Sep 2024 11:03:57 +0200 Subject: [PATCH 39/71] Translated using Weblate (Vietnamese) Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (718 of 718 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (717 of 717 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (698 of 698 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (692 of 692 strings) Co-authored-by: Draken Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/ Translation: Kotatsu/Strings --- app/src/main/res/values-vi/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0ae72648b..bd7f1d08c 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -312,7 +312,7 @@ Bạn có thể xoá file gốc khỏi bộ nhớ để tiết kiệm dung lượng Đặt lại Bảng tin - Chạm vào bên phải màn hình hoặc nhấn nút phải để chuyển tới trang tiếp theo. + Chạm vào bên phải màn hình hoặc nhấn nút mũi tên bên phải để sang trang kế tiếp. Điều này phụ thuộc vào thiết bị / giả lập bạn đang dùng (Không điều chỉnh hướng với chế độ đọc) Hiển thị lối tắt truyện đọc gần đây Tương phản Hiện thanh trượt chuyển trang From 81794e6eb2b9da6c8440fe043642243c1d7f53f9 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Tue, 24 Sep 2024 09:20:23 +0000 Subject: [PATCH 40/71] Translated using Weblate (Spanish) Currently translated at 100.0% (720 of 720 strings) Translation: Kotatsu/Strings Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/ --- app/src/main/res/values-es/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 03405eda6..6aaae5618 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -316,7 +316,7 @@ Hacer que los mangas recientes estén disponibles mediante una pulsación larga en el icono de la aplicación Fuente Mostrar los accesos directos a los mangas recientes - Tocando en el borde derecho, o pulsando la tecla derecha, se pasa siempre a la página siguiente. + No modifique el método de paso de página al modo de lectura ya configurado. Por ejemplo: presionar el botón derecho siempre pasa a la página siguiente. Esta configuración solo es válida para dispositivos de hardware. Control ergonómico del lector Corrección del color Brillo @@ -713,4 +713,6 @@ Seinen Josei Esta fuente no admite búsquedas con filtros. Se han eliminado los filtros + Un disparo + Kodomo \ No newline at end of file From 86dea2953a0bf19a4e7e35a8c87d3899907fcadf Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 24 Sep 2024 17:56:11 +0300 Subject: [PATCH 41/71] Update parsers --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1f6ae40ba..c64ac0512 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 671 - versionName = '7.6-a3' + versionCode = 672 + versionName = '7.6-b1' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:613623fa53') { + implementation('com.github.KotatsuApp:kotatsu-parsers:cc62981f12') { exclude group: 'org.json', module: 'json' } From 299093f8634f5fef0bb24be56c9f49152c6b0cd6 Mon Sep 17 00:00:00 2001 From: Faiz Faadhillah Date: Wed, 25 Sep 2024 01:16:39 +0700 Subject: [PATCH 42/71] Improve Spen integration support --- app/src/main/res/xml/remote_action.xml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/xml/remote_action.xml b/app/src/main/res/xml/remote_action.xml index 59db7cb3d..f6601a4cf 100644 --- a/app/src/main/res/xml/remote_action.xml +++ b/app/src/main/res/xml/remote_action.xml @@ -5,7 +5,7 @@ label="@string/prev_page" priority="1" trigger_key="L"> - + + + + + + + + + From eeb8dd8c5b6c97771310181de958395a9de1828f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Ersen?= Date: Wed, 25 Sep 2024 04:39:00 +0200 Subject: [PATCH 43/71] Translated using Weblate (Turkish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (720 of 720 strings) Co-authored-by: Oğuz Ersen Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/ Translation: Kotatsu/Strings --- app/src/main/res/values-tr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 70a3f4c08..9fece70e5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -713,4 +713,6 @@ Yıl Hepsi Bu kaynak filtrelerle aramayı desteklemiyor. Filtreleriniz temizlendi + Kodomo + Bir kerelik \ No newline at end of file From bf35a8ffd7e034c2303b8ffc3f7f350275c45c05 Mon Sep 17 00:00:00 2001 From: Infy's Tagalog Translations Date: Wed, 25 Sep 2024 04:39:01 +0200 Subject: [PATCH 44/71] Translated using Weblate (Filipino) Currently translated at 98.8% (712 of 720 strings) Co-authored-by: Infy's Tagalog Translations Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/ Translation: Kotatsu/Strings --- app/src/main/res/values-fil/strings.xml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 85d8b6796..61c03d05d 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -334,7 +334,7 @@ Makakuha ng paunawa tungkol sa mga unstable build Hindi magagamit ang network I-on ang Wi-Fi o mobile network para magbasa ng manga online - Ang pag-tap sa kanang gilid, o pagpindot sa kanan na key, ay palaging lilipat sa susunod na pahina. + Huwag i-adjust ang direksyon ng pagpalit ng pahina sa reader mode, hal. kapag pindutin ang kanan (right) na key ay palaging lumilipat sa susunod na pahina. Ang opsyong ito ay nakakaapekto lamang sa mga hardware input device Ipakita ang slider ng paglipat ng pahina Mga detalye ng error:<br><tt>%1$s</tt><br><br>1. Subukang <a href=%2$s>magbukas ng manga sa isang web browser</a> upang matiyak na available ito sa souce<br>2. Tiyaking ginagamit mo ang <a href=kotatsu://about>pinakabagong bersyon ng Kotatsu</a><br>3. Kung available ito, magpadala ng ulat ng error sa mga developer. Paganahin ang pag-log @@ -693,4 +693,18 @@ Ang Manga na \"%1$s\" (%2$s) ay pinalitan ng \"%3$s\" (%4$s) Di kinakailangan ng paayos sa \"%s\" Walang alternatibong nahanap para sa \"%s\" + Nobela + Kamakailang idinagdag + Matagal nang naidagdag + Sikat sa oras na ito + Sikat ngayong araw + Sikat ngayong linggo + Sikat ngayong buwan + Sikat ngayong taon + Orihinal na wika + Taon + Mga Taon + Kahit ano + Ang source na ito ay hindi sinusuportahan ang paghahanap na may mga filter. Ang iyong mga filter ay na-clear + Demograpiko \ No newline at end of file From d65874080bf8025e922b2fbc30542c262b0f75ce Mon Sep 17 00:00:00 2001 From: Draken Date: Wed, 25 Sep 2024 04:39:01 +0200 Subject: [PATCH 45/71] Translated using Weblate (Vietnamese) Currently translated at 100.0% (720 of 720 strings) Co-authored-by: Draken Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/ Translation: Kotatsu/Strings --- app/src/main/res/values-vi/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index bd7f1d08c..b47c26e48 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -713,4 +713,6 @@ Năm Bất kì Bộ lọc của bạn đã bị xóa do nguồn đọc này không hỗ trợ cho việc tìm kiếm bằng bộ lọc + Dành cho trẻ em + One shot \ No newline at end of file From 956831f9d70800e090f8d4bec99a36eeb82635ec Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 25 Sep 2024 09:47:17 +0300 Subject: [PATCH 46/71] Fix sync auth activity ui --- app/src/main/res/layout/activity_sync_auth.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/layout/activity_sync_auth.xml b/app/src/main/res/layout/activity_sync_auth.xml index 36629fb11..fad483c1a 100644 --- a/app/src/main/res/layout/activity_sync_auth.xml +++ b/app/src/main/res/layout/activity_sync_auth.xml @@ -161,6 +161,13 @@ android:visibility="gone" app:constraint_referenced_ids="textView_subtitle_2,button_back,button_done,layout_password" /> + + + app:layout_constraintTop_toBottomOf="@id/barrier_input" /> Date: Wed, 25 Sep 2024 12:28:10 +0300 Subject: [PATCH 47/71] Migrate external sources to new filter --- .../external/ExternalMangaRepository.kt | 36 ++--- .../external/ExternalPluginContentSource.kt | 130 ++++++++++++------ .../kotatsu/reader/domain/PageLoader.kt | 7 +- 3 files changed, 106 insertions(+), 67 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt index 148e5abff..2f4e9db24 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.util.EnumSet class ExternalMangaRepository( @@ -31,38 +32,19 @@ class ExternalMangaRepository( }.getOrNull() } + private val filterOptions = SuspendLazy(contentSource::getListFilterOptions) + override val sortOrders: Set - get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) + get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY) override val filterCapabilities: MangaListFilterCapabilities - get() = capabilities.let { - MangaListFilterCapabilities( - isMultipleTagsSupported = it?.isMultipleTagsSupported == true, - isTagsExclusionSupported = it?.isTagsExclusionSupported == true, - isSearchSupported = it?.isSearchSupported == true, - isSearchWithFiltersSupported = false, // TODO - isYearSupported = false, // TODO - isYearRangeSupported = false, // TODO - isOriginalLocaleSupported = false, // TODO - ) - } + get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities() override var defaultSortOrder: SortOrder - get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL + get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL set(value) = Unit - override suspend fun getFilterOptions(): MangaListFilterOptions = capabilities.let { - MangaListFilterOptions( - availableTags = runInterruptible(Dispatchers.IO) { - contentSource.getTags() - }, - availableStates = it?.availableStates.orEmpty(), - availableContentRating = it?.availableContentRating.orEmpty(), - availableContentTypes = emptySet(), - availableDemographics = emptySet(), - availableLocales = emptySet(), - ) - } + override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get() override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List = runInterruptible(Dispatchers.IO) { @@ -77,7 +59,9 @@ class ExternalMangaRepository( contentSource.getPages(chapter) } - override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO + override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) { + contentSource.getPageUrl(page.url) + } override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() // TODO } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt index c032b05a4..103b40d66 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt @@ -8,12 +8,14 @@ import androidx.core.net.toUri import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag @@ -29,6 +31,17 @@ class ExternalPluginContentSource( private val source: ExternalMangaSource, ) { + @Blocking + @WorkerThread + fun getListFilterOptions() = MangaListFilterOptions( + availableTags = fetchTags(), + availableStates = fetchEnumSet(MangaState::class.java, "filter/states"), + availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"), + availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"), + availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"), + availableLocales = fetchLocales(), + ) + @Blocking @WorkerThread fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { @@ -106,8 +119,8 @@ class ExternalPluginContentSource( @Blocking @WorkerThread - fun getTags(): Set { - val uri = "content://${source.authority}/tags".toUri() + private fun fetchTags(): Set { + val uri = "content://${source.authority}/filter/tags".toUri() return contentResolver.query(uri, null, null, null, null) .safe() .use { cursor -> @@ -125,6 +138,40 @@ class ExternalPluginContentSource( } } + @Blocking + @WorkerThread + fun getPageUrl(url: String): String { + val uri = "content://${source.authority}/pages/0".toUri().buildUpon() + .appendQueryParameter("url", url) + .build() + return contentResolver.query(uri, null, null, null, null) + .safe() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(COLUMN_VALUE) + } else { + url + } + } + } + + @Blocking + @WorkerThread + private fun fetchLocales(): Set { + val uri = "content://${source.authority}/filter/locales".toUri() + return contentResolver.query(uri, null, null, null, null) + .safe() + .use { cursor -> + val result = ArraySet(cursor.count) + if (cursor.moveToFirst()) { + do { + result += Locale(cursor.getString(COLUMN_NAME)) + } while (cursor.moveToNext()) + } + result + } + } + fun getCapabilities(): MangaSourceCapabilities? { val uri = "content://${source.authority}/capabilities".toUri() return contentResolver.query(uri, null, null, null, null) @@ -137,26 +184,18 @@ class ExternalPluginContentSource( ?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) { SortOrder.entries.find(it) }.orEmpty(), - availableStates = cursor.getStringOrNull(COLUMN_STATES) - ?.split(',') - ?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) { - MangaState.entries.find(it) - }.orEmpty(), - availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING) - ?.split(',') - ?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) { - ContentRating.entries.find(it) - }.orEmpty(), - isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true), - isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false), - isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true), - contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let { - ContentType.entries.find(it) - } ?: ContentType.OTHER, - defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let { - SortOrder.entries.find(it) - } ?: SortOrder.ALPHABETICAL, - sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT, + listFilterCapabilities = MangaListFilterCapabilities( + isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false), + isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false), + isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false), + isSearchWithFiltersSupported = cursor.getBooleanOrDefault( + COLUMN_SEARCH_WITH_FILTERS, + false, + ), + isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false), + isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false), + isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false), + ), ) } else { null @@ -226,6 +265,26 @@ class ExternalPluginContentSource( source = source, ) + private fun > fetchEnumSet(cls: Class, path: String): EnumSet { + val uri = "content://${source.authority}/$path".toUri() + return contentResolver.query(uri, null, null, null, null) + .safe() + .use { cursor -> + val result = EnumSet.noneOf(cls) + val enumConstants = cls.enumConstants ?: return@use result + if (cursor.moveToFirst()) { + do { + val name = cursor.getString(COLUMN_NAME) + val enumValue = enumConstants.find { it.name == name } + if (enumValue != null) { + result.add(enumValue) + } + } while (cursor.moveToNext()) + } + result + } + } + private fun Cursor?.safe() = ExternalPluginCursor( source = source, cursor = this ?: throw IncompatiblePluginException(source.name, null), @@ -233,27 +292,19 @@ class ExternalPluginContentSource( class MangaSourceCapabilities( val availableSortOrders: Set, - val availableStates: Set, - val availableContentRating: Set, - val isMultipleTagsSupported: Boolean, - val isTagsExclusionSupported: Boolean, - val isSearchSupported: Boolean, - val contentType: ContentType, - val defaultSortOrder: SortOrder, - val sourceLocale: Locale, + val listFilterCapabilities: MangaListFilterCapabilities, ) private companion object { const val COLUMN_SORT_ORDERS = "sort_orders" - const val COLUMN_STATES = "states" - const val COLUMN_CONTENT_RATING = "content_rating" - const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported" - const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported" - const val COLUMN_SEARCH_SUPPORTED = "search_supported" - const val COLUMN_CONTENT_TYPE = "content_type" - const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order" - const val COLUMN_LOCALE = "locale" + const val COLUMN_MULTIPLE_TAGS = "multiple_tags" + const val COLUMN_TAGS_EXCLUSION = "tags_exclusion" + const val COLUMN_SEARCH = "search" + const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters" + const val COLUMN_YEAR = "year" + const val COLUMN_YEAR_RANGE = "year_range" + const val COLUMN_ORIGINAL_LOCALE = "original_locale" const val COLUMN_ID = "id" const val COLUMN_NAME = "name" const val COLUMN_NUMBER = "number" @@ -275,5 +326,6 @@ class ExternalPluginContentSource( const val COLUMN_DESCRIPTION = "description" const val COLUMN_PREVIEW = "preview" const val COLUMN_KEY = "key" + const val COLUMN_VALUE = "value" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 216804742..14e7cccc2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.reader.domain import android.content.Context +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Rect import android.net.Uri @@ -140,7 +141,7 @@ class PageLoader @Inject constructor( val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) zip.getInputStream(zip.getEntry(uri.fragment)).use { - BitmapFactory.decodeStream(it) + checkBitmapNotNull(BitmapFactory.decodeStream(it)) } } } @@ -149,7 +150,7 @@ class PageLoader @Inject constructor( val file = uri.toFile() context.ensureRamAtLeast(file.length() * 2) runInterruptible(Dispatchers.IO) { - BitmapFactory.decodeFile(file.absolutePath) + checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath)) }.use { image -> image.compressToPNG(file) } @@ -245,6 +246,8 @@ class PageLoader @Inject constructor( return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) } + private fun checkBitmapNotNull(bitmap: Bitmap?): Bitmap = checkNotNull(bitmap) { "Cannot decode bitmap" } + private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() From 41cfd99d32cd6592dbd32e75a248c214e0676349 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 26 Sep 2024 12:33:35 +0300 Subject: [PATCH 48/71] Fix applying filter --- .../kotatsu/filter/ui/FilterCoordinator.kt | 40 ++++++++++++++++--- .../kotatsu/filter/ui/FilterHeaderFragment.kt | 11 +---- .../kotatsu/filter/ui/FilterHeaderProducer.kt | 11 +++++ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 248760075..110bf6373 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -267,32 +267,50 @@ class FilterCoordinator @Inject constructor( } fun setQuery(value: String?) { + val newQuery = value?.trim()?.takeUnless { it.isEmpty() } currentListFilter.update { oldValue -> - oldValue.copy(query = value?.trim()?.takeUnless { it.isEmpty() }) + if (capabilities.isSearchWithFiltersSupported || newQuery == null) { + oldValue.copy(query = newQuery) + } else { + MangaListFilter(query = newQuery) + } } } fun setLocale(value: Locale?) { currentListFilter.update { oldValue -> - oldValue.copy(locale = value) + oldValue.copy( + locale = value, + query = oldValue.takeQueryIfSupported(), + ) } } fun setOriginalLocale(value: Locale?) { currentListFilter.update { oldValue -> - oldValue.copy(originalLocale = value) + oldValue.copy( + originalLocale = value, + query = oldValue.takeQueryIfSupported(), + ) } } fun setYear(value: Int) { currentListFilter.update { oldValue -> - oldValue.copy(year = value) + oldValue.copy( + year = value, + query = oldValue.takeQueryIfSupported(), + ) } } fun setYearRange(valueFrom: Int, valueTo: Int) { currentListFilter.update { oldValue -> - oldValue.copy(yearFrom = valueFrom, yearTo = valueTo) + oldValue.copy( + yearFrom = valueFrom, + yearTo = valueTo, + query = oldValue.takeQueryIfSupported(), + ) } } @@ -300,6 +318,7 @@ class FilterCoordinator @Inject constructor( currentListFilter.update { oldValue -> oldValue.copy( states = if (isSelected) oldValue.states + value else oldValue.states - value, + query = oldValue.takeQueryIfSupported(), ) } } @@ -308,6 +327,7 @@ class FilterCoordinator @Inject constructor( currentListFilter.update { oldValue -> oldValue.copy( contentRating = if (isSelected) oldValue.contentRating + value else oldValue.contentRating - value, + query = oldValue.takeQueryIfSupported(), ) } } @@ -316,6 +336,7 @@ class FilterCoordinator @Inject constructor( currentListFilter.update { oldValue -> oldValue.copy( types = if (isSelected) oldValue.types + value else oldValue.types - value, + query = oldValue.takeQueryIfSupported(), ) } } @@ -330,6 +351,7 @@ class FilterCoordinator @Inject constructor( oldValue.copy( tags = newTags, tagsExclude = oldValue.tagsExclude - newTags, + query = oldValue.takeQueryIfSupported(), ) } } @@ -344,6 +366,7 @@ class FilterCoordinator @Inject constructor( oldValue.copy( tags = oldValue.tags - newTagsExclude, tagsExclude = newTagsExclude, + query = oldValue.takeQueryIfSupported(), ) } } @@ -352,6 +375,13 @@ class FilterCoordinator @Inject constructor( it.map { x -> x.availableTags.sortedWithSafe(TagTitleComparator(sourceLocale)) } } + private fun MangaListFilter.takeQueryIfSupported() = when { + capabilities.isSearchWithFiltersSupported -> query + query.isNullOrEmpty() -> query + hasNonSearchOptions() -> null + else -> query + } + private fun getTopTags(limit: Int): Flow>> = combine( flow { emit(searchRepository.getTopTags(repository.source, limit)) }, filterOptions.asFlow(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 57662e12c..0dc0271e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -9,7 +9,6 @@ import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled @@ -19,7 +18,6 @@ import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.MangaTag import javax.inject.Inject -import com.google.android.material.R as materialR @AndroidEntryPoint class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener, @@ -68,17 +66,12 @@ class FilterHeaderFragment : BaseFragment(), ChipsV binding.root.isVisible = false return } + binding.chipsTags.setChips(header.chips) + binding.root.isVisible = true if (binding.root.context.isAnimationsEnabled) { binding.scrollView.smoothScrollTo(0, 0) } else { binding.scrollView.scrollTo(0, 0) } - binding.chipsTags.setChips(header.chips + moreTagsChip()) - binding.root.isVisible = true } - - private fun moreTagsChip() = ChipsView.ChipModel( - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index 9b6ef87c1..c29c0eeec 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterProperty @@ -86,6 +87,16 @@ class FilterHeaderProducer @Inject constructor( ), ) } + val hasTags = result.any { it.data is MangaTag } + if (hasTags) { + result.addLast(moreTagsChip()) + } return result } + + private fun moreTagsChip() = ChipsView.ChipModel( + titleResId = R.string.more, + isDropdown = true, + // icon = materialR.drawable.abc_ic_menu_overflow_material, + ) } From 8d35101e98c9d5f7709822f16f0b2a6ba3824dc9 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 26 Sep 2024 14:46:36 +0300 Subject: [PATCH 49/71] Update parsers --- app/build.gradle | 6 +-- .../kotatsu/core/model/MangaSource.kt | 4 ++ .../kotatsu/core/parser/BitmapWrapper.kt | 10 +++-- .../core/parser/MangaLoaderContextImpl.kt | 40 ++++++++----------- .../core/parser/favicon/FaviconFetcher.kt | 2 +- .../koitharu/kotatsu/core/util/ext/Http.kt | 3 -- app/src/main/res/values/strings.xml | 4 ++ 7 files changed, 36 insertions(+), 33 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c64ac0512..760c4440c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 672 - versionName = '7.6-b1' + versionCode = 673 + versionName = '7.6' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:cc62981f12') { + implementation('com.github.KotatsuApp:kotatsu-parsers:3cdd391410') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 9c8ff510d..ce77eed36 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -62,6 +62,10 @@ val ContentType.titleResId ContentType.MANHUA -> R.string.content_type_manhua ContentType.NOVEL -> R.string.content_type_novel ContentType.ONE_SHOT -> R.string.content_type_one_shot + ContentType.DOUJINSHI -> R.string.content_type_doujinshi + ContentType.IMAGE_SET -> R.string.content_type_image_set + ContentType.ARTIST_CG -> R.string.content_type_artist_cg + ContentType.GAME_CG -> R.string.content_type_game_cg } tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapWrapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapWrapper.kt index 13d429091..09d2d6ec8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapWrapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapWrapper.kt @@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect class BitmapWrapper private constructor( private val androidBitmap: AndroidBitmap, -) : Bitmap { +) : Bitmap, AutoCloseable { private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily @@ -24,17 +24,21 @@ class BitmapWrapper private constructor( canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null) } + override fun close() { + androidBitmap.recycle() + } + fun compressTo(output: OutputStream) { androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output) } companion object { - fun create(width: Int, height: Int): Bitmap = BitmapWrapper( + fun create(width: Int, height: Int) = BitmapWrapper( AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888), ) - fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper( + fun create(bitmap: AndroidBitmap) = BitmapWrapper( if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index a4fe4fa25..b6fb9f9f3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -21,14 +21,15 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.requireBody import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.toList +import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.bitmap.Bitmap import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.UserAgents +import org.koitharu.kotatsu.parsers.util.map import java.lang.ref.WeakReference import java.util.Locale import javax.inject.Inject @@ -76,32 +77,25 @@ class MangaLoaderContextImpl @Inject constructor( } override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response { - val image = response.requireBody().byteStream() - - val opts = BitmapFactory.Options() - opts.inMutable = true - val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap") - val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper - - val body = Buffer().also { - result.compressTo(it.outputStream()) - }.asResponseBody("image/jpeg".toMediaType()) - - return response.newBuilder() - .body(body) - .build() + return response.map { body -> + val opts = BitmapFactory.Options() + opts.inMutable = true + BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap -> + (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> + Buffer().also { + result.compressTo(it.outputStream()) + }.asResponseBody("image/jpeg".toMediaType()) + } + } ?: error("Cannot decode bitmap") + } } - override fun createBitmap(width: Int, height: Int): Bitmap { - return BitmapWrapper.create(width, height) - } + override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height) @MainThread - private fun obtainWebView(): WebView { - return webViewCached?.get() ?: WebView(androidContext).also { - it.configureForParser(null) - webViewCached = WeakReference(it) - } + private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also { + it.configureForParser(null) + webViewCached = WeakReference(it) } private fun obtainWebViewUserAgent(): String { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index 035e14c9e..1d28509c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -37,12 +37,12 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository -import org.koitharu.kotatsu.core.util.ext.requireBody import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.requireBody import java.net.HttpURLConnection import kotlin.coroutines.coroutineContext diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt index 018d594e1..5d384ba88 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt @@ -5,7 +5,6 @@ import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import okhttp3.ResponseBody import okhttp3.internal.closeQuietly import okio.IOException import org.json.JSONObject @@ -41,8 +40,6 @@ fun Response.ensureSuccess() = apply { } } -fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" } - fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> c.name(name) c.value(value) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2076ad1f7..2648670ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -729,4 +729,8 @@ This source does not support search with filters. Your filters have been cleared Kodomo One shot + Doujinshi + Image set + Artist CG + Game CG From 30f1b2c73a1356303361895ec3b5f2c776293d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D0=B0=D1=80=20=D0=A0=D0=B0=D0=B7=D0=B8?= =?UTF-8?q?=D0=BD?= Date: Thu, 26 Sep 2024 14:34:04 +0200 Subject: [PATCH 50/71] Translated using Weblate (Russian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (720 of 720 strings) Translated using Weblate (Belarusian) Currently translated at 100.0% (720 of 720 strings) Co-authored-by: Макар Разин Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/ Translation: Kotatsu/Strings --- app/src/main/res/values-be/strings.xml | 33 +++++++++++++++++++++++++- app/src/main/res/values-ru/strings.xml | 33 +++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 71a268b5f..dfbe486f9 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -303,7 +303,7 @@ Дэталі памылкі:<br><tt>%1$s</tt><br><br>1. Паспрабуйце <a href=%2$s>адкрыць мангу ў вэб-браўзеры</a>, каб пераканацца, што яна даступная ў крыніцы<br>2. Упэўніцеся, што вы выкарыстоўваеце <a href=kotatsu://about>апошнюю версію Kotatsu</a><br>3. Калі ён даступны, адпраўце распрацоўнікам справаздачу аб памылцы. Паказаць апошнія ярлыкі мангі Зрабіце нядаўнюю мангу даступнай, доўга націскаючы на значок праграмы - Націск на правы край або націск правай клавішы заўсёды перамыкае на наступную старонку. + Не настройвайце кірунак пераключэння старонак у рэжым чытання, напрыклад, націск правай клавішы заўсёды перамыкае на наступную старонку. Гэтая опцыя ўплывае толькі на апаратныя прылады ўводу Эрганамічнае кіраванне рэжымам чытання Карэкцыя колеру Яркасць @@ -684,4 +684,35 @@ Гэта функцыя эксперыментальная. Пераканайцеся, што ў вас ёсць рэзервовая копія, каб пазбегнуць страты даных Увайдзіце, каб настроіць інтэграцыю з %s. Гэта дазволіць вам адсочваць прагрэс і статус чытання мангі Завісла + Навэла + Маньхуа + Манхва + Нядаўна дададзена + Дадаў даўно + Папулярна сёння + Папулярна на гэтым тыдні + Папулярна ў гэтым месяцы + Папулярна ў гэтым годзе + Зыходная мова + Год + Папулярна ў гэтую гадзіну + Сёнэн + Дэмаграфія + Сёдзё + Сэйнэн + Дзёсэй + Гады + Любой + Ваншот + Гэтая крыніца не падтрымлівае пошук з фільтрамі. Вашы фільтры былі ачышчаны + Кодомо + Фонавыя загрузкі + Манга з загружанымі раздзеламі + Спампаваць новыя раздзелы + Манга «%1$s» (%2$s) заменена на «%3$s» (%4$s) + Выпраўленне мангі + Выпраўлена паспяхова + Выпраўленне для «%s» не патрабуецца + Альтэрнатывы для «%s» не знойдзены + Гэтая функцыя знойдзе альтэрнатыўныя крыніцы для абранай мангі. Задача зойме некаторы час і будзе выконвацца ў фонавым рэжыме \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 50ce2c74a..eb2ff247e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -316,7 +316,7 @@ Сведения об ошибке:<br><tt>%1$s</tt><br><br>1. Попробуйте <a href=%2$s>открыть мангу в веб-браузере</a>, чтобы убедиться, что она доступна в источнике<br>2. Убедитесь, что вы используете <a href=kotatsu://about>последнюю версию Kotatsu</a><br>3. Если возможно, отправьте отчёт об ошибке разработчикам. Показывать ярлыки последней прочитанной манги Сделать недавно прочитанную мангу доступной по долгому нажатию на иконку приложения - Нажатие на правый край или нажатие правой клавиши всегда переключает на следующую страницу. + Не настраивайте направление переключения страниц в режим чтения, например, нажатие правой клавиши всегда переключает на следующую страницу. Эта опция влияет только на аппаратные устройства ввода Эргономичное управление режимом чтения Сбросить Отклонить @@ -684,4 +684,35 @@ Нестабильная функция Войдите, чтобы настроить интеграцию с %s. Это позволит вам отслеживать прогресс и статус чтения манги Эта функция экспериментальная. Убедитесь, что у вас есть резервная копия, чтобы избежать потери данных + Новелла + Маньхуа + Популярно в этот час + Популярно сегодня + Популярно на этой неделе + Популярно в этом месяце + Год + Демография + Сёнэн + Сэйнэн + Дзёсэй + Годы + Любой + Кодомо + Ваншот + Манхва + Недавно добавлено + Популярно в этом году + Исходный язык + Добавлено давно + Сёдзё + Этот источник не поддерживает поиск с фильтрами. Ваши фильтры были очищены + Фоновые загрузки + Скачать новые главы + Манга с загруженными главами + Исправление манги + Исправлено успешно + Эта функция найдёт альтернативные источники для выбранной манги. Задача займёт некоторое время и будет выполняться в фоновом режиме + Манга «%1$s» (%2$s) заменена на «%3$s» (%4$s) + Исправление для «%s» не требуется + Альтернативы для «%s» не найдены \ No newline at end of file From 3241ae5db5286e203356770001b6cf669795a83f Mon Sep 17 00:00:00 2001 From: Felipe Nascimento Date: Thu, 26 Sep 2024 14:34:04 +0200 Subject: [PATCH 51/71] Translated using Weblate (Portuguese) Currently translated at 98.7% (711 of 720 strings) Co-authored-by: Felipe Nascimento Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/ Translation: Kotatsu/Strings --- app/src/main/res/values-pt/strings.xml | 48 +++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5287b2e08..c5afb53b0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -331,7 +331,7 @@ A importação começará em breve Fluxo Detalhes do erro:<br><tt>%1$s</tt><br><br>1. Tente <a href=%2$s>abra a página do mangá em um navegador da web</a> para garantir que o mesmo esteja disponível em sua fonte<br>2. Se estiver disponível, envie um relatório de erro para os desenvolvedores. - Tocar na borda direita ou pressionar a tecla direita, sempre muda para a próxima página. + Tocar a borda direita ou pressionar a tecla direita, sempre mudará para a próxima página. Controle ergonômico do leitor Descartar Idioma @@ -661,4 +661,50 @@ Não há mangás que correspondam aos filtros selecionados A conexão está OK Configuração de proxy inválida + Entre em %s para continuar + Entre para configurar a integração com %s. Isso permitirá que você acompanhe o progresso e o status da sua leitura + Característica instável + Esta função é experimental. Certifique-se de ter um backup para evitar perda de dados + Romance + + Adicionado recentemente + Adicionado há muito tempo + Popular hoje + Popular essa semana + Popular esse mês + Popular esse ano + Popular neste momento + Idioma original + Ano + Demografia + + Anos + Qualquer + Endereço de Servidor Inválido + Muitas solicitações. Tente novamente depois %s + %d s + Mostrar filtros rápidos + Pular tudo + Não está nos favoritos + %1$d m %2$d s + Oferece a capacidade de filtrar listas de mangás por determinados parâmetros + Impopular + Descendente + Esta fonte não suporta pesquisa com filtros. Seus filtros foram limpos + Baixar em segundo plano + Baixar novos capítulos + Mangá com capítulos baixados + Corrigindo mangá + Corrigido com sucesso + Nenhuma correção necessária para \"%s\" + Nenhuma alternativa encontrada para \"%s\" + Esta função encontrará fontes alternativas para o mangá selecionado. A tarefa levará algum tempo e prosseguirá em segundo plano + Mangá \"%1$s\" (%2$s) substituído por \"%3$s\" (%4$s) + Tentar Novamente + Preso + Atualizado há muito tempo + Classificação baixa + Ascendente + Data + Popularidade \ No newline at end of file From 1b9f886d1b7e5556e87894fb10973552e059b3c0 Mon Sep 17 00:00:00 2001 From: Infy's Tagalog Translations Date: Thu, 26 Sep 2024 14:34:04 +0200 Subject: [PATCH 52/71] Translated using Weblate (Filipino) Currently translated at 98.4% (713 of 724 strings) Translated using Weblate (Filipino) Currently translated at 98.8% (712 of 720 strings) Co-authored-by: Infy's Tagalog Translations Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/ Translation: Kotatsu/Strings --- app/src/main/res/values-fil/strings.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 61c03d05d..6996c6a10 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -64,7 +64,7 @@ Pagkasyahin sa gitna Panatilihin sa simula Gumagamit ng mas kaunting power sa mga AMOLED na screen - I-backup at i-restore + I-backup at I-restore Naibalik na Naghahanda… Hindi nahanap ang file @@ -165,8 +165,8 @@ Itim Lumikha ng data backup I-restore mula sa backup - Naibalik ang lahat ng data - Ang data ay naibalik, ngunit may mga error + Na-restore ang lahat ng data + Ang data ay na-restore, ngunit may mga error Ang napiling konpigurasyon ay maaalala para sa manga na ito Isalin ang app na ito Awtorisado na @@ -707,4 +707,5 @@ Kahit ano Ang source na ito ay hindi sinusuportahan ang paghahanap na may mga filter. Ang iyong mga filter ay na-clear Demograpiko + Set ng mga imahe \ No newline at end of file From 4efdb1d8d1b0bbe21c15377441d68d71352b632a Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Thu, 26 Sep 2024 14:34:04 +0200 Subject: [PATCH 53/71] Update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ Translation: Kotatsu/Strings --- app/src/main/res/values-pt/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c5afb53b0..8bd3b6c58 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -666,7 +666,6 @@ Característica instável Esta função é experimental. Certifique-se de ter um backup para evitar perda de dados Romance - Adicionado recentemente Adicionado há muito tempo Popular hoje @@ -677,7 +676,6 @@ Idioma original Ano Demografia - Anos Qualquer Endereço de Servidor Inválido From 826587b2c9e8199600801c89c895945ea0c7d618 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 26 Sep 2024 14:34:05 +0200 Subject: [PATCH 54/71] Translated using Weblate (Russian) Currently translated at 99.7% (722 of 724 strings) Co-authored-by: Koitharu Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/ Translation: Kotatsu/Strings --- app/src/main/res/values-ru/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eb2ff247e..a3613d6dc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -715,4 +715,6 @@ Манга «%1$s» (%2$s) заменена на «%3$s» (%4$s) Исправление для «%s» не требуется Альтернативы для «%s» не найдены + Додзинси + Набор изображений \ No newline at end of file From 4fb3173185f3f4e6007da4cf4349a50750ba81d3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 26 Sep 2024 16:02:52 +0300 Subject: [PATCH 55/71] Update readme --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2178fc652..756ce75ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Kotatsu -Kotatsu is a free and open source manga reader for Android. +Kotatsu is a free and open-source manga reader for Android with built-in online content sources. -![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) +[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE) ### Download @@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android. ### Main Features * Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) -* Search manga by name and genres +* Search manga by name, genres, and more filters * Reading history and bookmarks -* Favourites organized by user-defined categories +* Favorites organized by user-defined categories * Downloading manga and reading it offline. Third-party CBZ archives also supported * Tablet-optimized Material You UI -* Standard and Webtoon-optimized reader +* Standard and Webtoon-optimized customizable reader * Notifications about new chapters with updates feed * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu -* Password/fingerprint protect access to the app -* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices +* Password/fingerprint-protected access to the app ### Screenshots From 26b512d42e7e461d2dade97d3153d0a8b960b18a Mon Sep 17 00:00:00 2001 From: Mac135135 Date: Sat, 28 Sep 2024 19:10:56 +0300 Subject: [PATCH 56/71] Added an periodical backup to the telegram bot --- .../kotatsu/core/prefs/AppSettings.kt | 8 ++ .../PeriodicalBackupSettingsFragment.kt | 100 ++++++++++++++++++ .../settings/backup/PeriodicalBackupWorker.kt | 43 +++++++- app/src/main/res/xml/pref_backup_periodic.xml | 12 +++ 4 files changed, 162 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 3d3b82081..468a1a84a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -41,6 +41,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val connectivityManager = context.connectivityManager + private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + + + var telegramChatId: String? + get() = preferences.getString("telegram_chat_id", null) + set(value) { + preferences.edit().putString("telegram_chat_id", value).apply() + } var listMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index 3e5dfd316..cb8ec0267 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -20,7 +21,13 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import java.io.File +import java.io.IOException import java.text.SimpleDateFormat import javax.inject.Inject @@ -38,7 +45,100 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) + + val openTelegramBotPreference = findPreference("open_telegram_chat") + + openTelegramBotPreference?.setOnPreferenceClickListener { + openTelegramBot("kotatsu_backup_bot") + true + } + // Кнопка для проверки работы API + val checkApiButton = Preference(requireContext()).apply { + key = "check_api_working" + title = "Проверить работу API" + summary = "Нажмите для проверки работы Telegram Bot API" + } + + checkApiButton.setOnPreferenceClickListener { + val apiKey = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM" // Получите API Key из настроек + if (apiKey.isNotEmpty()) { + checkTelegramBotApiKey(apiKey) + } else { + Toast.makeText(requireContext(), "Введите API Key в настройках!", Toast.LENGTH_SHORT).show() + } + true + } + + preferenceScreen.addPreference(checkApiButton) } + private fun checkTelegramBotApiKey(apiKey: String) { + val url = "https://api.telegram.org/bot$apiKey/getMe" + + val client = OkHttpClient() + val request = Request.Builder() + .url(url) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + requireActivity().runOnUiThread { + if (response.isSuccessful) { + sendMessageToTelegram(apiKey, "Kotatsu's backup in Telegram is working!!") + } + } + } + + override fun onFailure(call: Call, e: IOException) { + requireActivity().runOnUiThread { + Toast.makeText(requireContext(), "Network error! Check your Net", Toast.LENGTH_SHORT).show() + } + } + }) + } + private fun openTelegramBot(botUsername: String) { + try { + val telegramIntent = Intent(Intent.ACTION_VIEW) + telegramIntent.data = Uri.parse("https://t.me/$botUsername") + telegramIntent.setPackage("org.telegram.messenger") + startActivity(telegramIntent) + } catch (e: Exception) { + // Если Telegram не установлен, открываем через браузер + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$botUsername")) + startActivity(browserIntent) + } + } + private fun sendMessageToTelegram(apiKey: String, message: String) { + val chatId = settings.telegramChatId + if (chatId.isNullOrEmpty()) { + Toast.makeText(requireContext(), "Chat ID is not set!", Toast.LENGTH_SHORT).show() + return + } + + val url = "https://api.telegram.org/bot$apiKey/sendMessage?chat_id=$chatId&text=$message" + val client = OkHttpClient() + val request = Request.Builder() + .url(url) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + requireActivity().runOnUiThread { + if (response.isSuccessful) { + Toast.makeText(requireContext(), "Success! Check Telegram Bot", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(requireContext(), "OOPS! Something went wrong", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onFailure(call: Call, e: IOException) { + requireActivity().runOnUiThread { + Toast.makeText(requireContext(), "Network error!", Toast.LENGTH_SHORT).show() + } + } + }) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt index 224df7b7b..e585b2f03 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -25,6 +25,15 @@ import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Inject +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response +import java.io.File @HiltWorker class PeriodicalBackupWorker @AssistedInject constructor( @@ -54,8 +63,40 @@ class PeriodicalBackupWorker @AssistedInject constructor( applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output -> file.inputStream().copyTo(output) } ?: return Result.failure() + + val botToken = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM" + val chatId = settings.telegramChatId ?: return Result.failure() + + val success = sendBackupToTelegram(file, botToken, chatId) + file.deleteAwait() - return Result.success(resultData) + + return if (success) { + Result.success(resultData) + } else { + Result.failure() + } + } + + fun sendBackupToTelegram(file: File, botToken: String, chatId: String): Boolean { + val client = OkHttpClient() + val mediaType = "application/zip".toMediaTypeOrNull() + val requestBody = file.asRequestBody(mediaType) + + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("chat_id", chatId) + .addFormDataPart("document", file.name, requestBody) + .build() + + val request = Request.Builder() + .url("https://api.telegram.org/bot$botToken/sendDocument") + .post(multipartBody) + .build() + + client.newCall(request).execute().use { response -> + return response.isSuccessful + } } @Reusable diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index a205e8657..db089196b 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -32,4 +32,16 @@ app:allowDividerAbove="true" app:isPreferenceVisible="false" /> + + + + From e8d04644f8661c91e873aaf36fe1a2188f60b144 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 27 Sep 2024 14:40:31 +0300 Subject: [PATCH 57/71] Remove loggers and reorganize settings --- .../koitharu/kotatsu/core/logs/FileLogger.kt | 148 ------------------ .../org/koitharu/kotatsu/core/logs/Loggers.kt | 11 -- .../kotatsu/core/logs/LoggersModule.kt | 40 ----- .../kotatsu/core/network/NetworkModule.kt | 2 +- .../koitharu/kotatsu/core/network/SSLUtils.kt | 2 +- .../kotatsu/core/prefs/AppSettings.kt | 10 +- .../kotatsu/core/ui/BasePreferenceFragment.kt | 12 +- .../koitharu/kotatsu/core/util/ShareHelper.kt | 23 --- .../settings/about/AboutSettingsFragment.kt | 42 +++-- .../tracker/TrackerSettingsFragment.kt | 6 + .../settings/utils/AboutLinksPreference.kt | 62 -------- .../kotatsu/sync/domain/SyncHelper.kt | 26 +-- .../kotatsu/tracker/work/TrackWorker.kt | 10 +- app/src/main/res/values/constants.xml | 5 +- app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/pref_about.xml | 65 ++++---- app/src/main/res/xml/pref_tracker.xml | 42 ++--- 17 files changed, 107 insertions(+), 403 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt deleted file mode 100644 index 60a1e1d83..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt +++ /dev/null @@ -1,148 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import android.content.Context -import androidx.annotation.WorkerThread -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.core.util.ext.subdir -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.io.File -import java.io.FileOutputStream -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale -import java.util.concurrent.ConcurrentLinkedQueue - -private const val DIR = "logs" -private const val FLUSH_DELAY = 2_000L -private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB - -class FileLogger( - context: Context, - private val settings: AppSettings, - name: String, -) { - - val file by lazy { - val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR) - File(dir, "$name.log") - } - val isEnabled: Boolean - get() = settings.isLoggingEnabled - private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT) - private val buffer = ConcurrentLinkedQueue() - private val mutex = Mutex() - private var flushJob: Job? = null - - fun log(message: String, e: Throwable? = null) { - if (!isEnabled) { - return - } - val text = buildString { - append(dateTimeFormatter.format(LocalDateTime.now())) - append(": ") - if (e != null) { - append("E!") - } - append(message) - if (e != null) { - append(' ') - append(e.stackTraceToString()) - appendLine() - } - } - buffer.add(text) - postFlush() - } - - inline fun log(messageProducer: () -> String) { - if (isEnabled) { - log(messageProducer()) - } - } - - suspend fun flush() { - if (!isEnabled) { - return - } - flushJob?.cancelAndJoin() - flushImpl() - } - - @WorkerThread - fun flushBlocking() { - if (!isEnabled) { - return - } - runBlockingSafe { flushJob?.cancelAndJoin() } - runBlockingSafe { flushImpl() } - } - - private fun postFlush() { - if (flushJob?.isActive == true) { - return - } - flushJob = processLifecycleScope.launch(Dispatchers.Default) { - delay(FLUSH_DELAY) - runCatchingCancellable { - flushImpl() - }.onFailure { - it.printStackTraceDebug() - } - } - } - - private suspend fun flushImpl() = withContext(NonCancellable) { - mutex.withLock { - if (buffer.isEmpty()) { - return@withContext - } - runInterruptible(Dispatchers.IO) { - if (file.length() > MAX_SIZE_BYTES) { - rotate() - } - FileOutputStream(file, true).use { - while (true) { - val message = buffer.poll() ?: break - it.write(message.toByteArray()) - it.write('\n'.code) - } - it.flush() - } - } - } - } - - @WorkerThread - private fun rotate() { - val length = file.length() - val bakFile = File(file.parentFile, file.name + ".bak") - file.renameTo(bakFile) - bakFile.inputStream().use { input -> - input.skip(length - MAX_SIZE_BYTES / 2) - file.outputStream().use { output -> - input.copyTo(output) - output.flush() - } - } - bakFile.delete() - } - - private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try { - runBlocking(NonCancellable) { block() } - } catch (_: InterruptedException) { - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt deleted file mode 100644 index 008ca7d92..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class TrackerLogger - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class SyncLogger diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt deleted file mode 100644 index 8253044d8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import android.content.Context -import androidx.collection.arraySetOf -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.ElementsIntoSet -import org.koitharu.kotatsu.core.prefs.AppSettings - -@Module -@InstallIn(SingletonComponent::class) -object LoggersModule { - - @Provides - @TrackerLogger - fun provideTrackerLogger( - @ApplicationContext context: Context, - settings: AppSettings, - ) = FileLogger(context, settings, "tracker") - - @Provides - @SyncLogger - fun provideSyncLogger( - @ApplicationContext context: Context, - settings: AppSettings, - ) = FileLogger(context, settings, "sync") - - @Provides - @ElementsIntoSet - fun provideAllLoggers( - @TrackerLogger trackerLogger: FileLogger, - @SyncLogger syncLogger: FileLogger, - ): Set<@JvmSuppressWildcards FileLogger> = arraySetOf( - trackerLogger, - syncLogger, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt index 8d617cb68..0200b99c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -74,7 +74,7 @@ interface NetworkModule { if (settings.isSSLBypassEnabled) { disableCertificateVerification() } else { - installExtraCertsificates(contextProvider.get()) + installExtraCertificates(contextProvider.get()) } cache(cache) addInterceptor(GZipInterceptor()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLUtils.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLUtils.kt index 505ebc6d3..fa1c44911 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLUtils.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLUtils.kt @@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder -> } } -fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder -> +fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder -> val certificatesBuilder = HandshakeCertificates.Builder() .addPlatformTrustedCertificates() val assets = context.assets.list("").orEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 468a1a84a..84a7c2db3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -247,9 +247,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { } } ?: EnumSet.allOf(SearchSuggestionType::class.java) - val isLoggingEnabled: Boolean - get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false) - var isBiometricProtectionEnabled: Boolean get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } @@ -673,7 +670,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out" const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_APP_LOCALE = "app_locale" - const val KEY_LOGGING_ENABLED = "logging" const val KEY_SOURCES_GRID = "sources_grid" const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_TIPS_CLOSED = "tips_closed" @@ -717,9 +713,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_APP_VERSION = "app_version" const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_TRACKER_DEBUG = "tracker_debug" - const val KEY_LOGS_SHARE = "logs_share" const val KEY_APP_UPDATE = "app_update" - const val KEY_APP_TRANSLATION = "about_app_translation" + const val KEY_LINK_WEBLATE = "about_app_translation" + const val KEY_LINK_TELEGRAM = "about_telegram" + const val KEY_LINK_GITHUB = "about_github" + const val KEY_LINK_MANUAL = "about_help" const val PROXY_TEST = "proxy_test" // old keys are for migration only diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index 43b29b380..357e792e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -80,11 +80,11 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : (activity as? SettingsActivity)?.setSectionTitle(title) } - protected fun startActivitySafe(intent: Intent) { - try { - startActivity(intent) - } catch (_: ActivityNotFoundException) { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - } + protected fun startActivitySafe(intent: Intent): Boolean = try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt index c16502090..fc486cb4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt @@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.util import android.content.Context import android.net.Uri -import android.widget.Toast import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.parsers.model.Manga import java.io.File @@ -84,25 +82,4 @@ class ShareHelper(private val context: Context) { .setChooserTitle(R.string.share) .startChooser() } - - fun shareLogs(loggers: Collection) { - val intentBuilder = ShareCompat.IntentBuilder(context) - .setType(TYPE_TEXT) - var hasLogs = false - for (logger in loggers) { - val logFile = logger.file - if (!logFile.exists()) { - continue - } - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile) - intentBuilder.addStream(uri) - hasLogs = true - } - if (hasLogs) { - intentBuilder.setChooserTitle(R.string.share_logs) - intentBuilder.startChooser() - } else { - Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show() - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index 944355452..cc6655248 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.settings.about import android.content.Intent import android.os.Bundle import android.view.View +import androidx.annotation.StringRes import androidx.core.net.toUri import androidx.fragment.app.viewModels import androidx.preference.Preference @@ -14,23 +15,16 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.VersionId import org.koitharu.kotatsu.core.github.isStable -import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity -import javax.inject.Inject @AndroidEntryPoint class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { private val viewModel by viewModels() - @Inject - lateinit var loggers: Set<@JvmSuppressWildcards FileLogger> - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_about) findPreference(AppSettings.KEY_APP_VERSION)?.run { @@ -41,12 +35,6 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable if (!isEnabled) isChecked = true } - if (!settings.isTrackerEnabled) { - findPreference(AppSettings.KEY_TRACKER_DEBUG)?.run { - isEnabled = false - setSummary(R.string.check_for_new_chapters_disabled) - } - } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -64,21 +52,27 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { true } - AppSettings.KEY_APP_TRANSLATION -> { - openLink(getString(R.string.url_weblate), preference.title) + AppSettings.KEY_LINK_WEBLATE -> { + openLink(R.string.url_weblate, preference.title) true } - AppSettings.KEY_LOGS_SHARE -> { - ShareHelper(preference.context).shareLogs(loggers) + AppSettings.KEY_LINK_GITHUB -> { + openLink(R.string.url_github, preference.title) true } - AppSettings.KEY_TRACKER_DEBUG -> { - startActivity(Intent(preference.context, TrackerDebugActivity::class.java)) + AppSettings.KEY_LINK_MANUAL -> { + openLink(R.string.url_user_manual, preference.title) true } + AppSettings.KEY_LINK_TELEGRAM -> { + if (!openLink(R.string.url_telegram, null)) { + openLink(R.string.url_telegram_web, preference.title) + } + true + } else -> super.onPreferenceTreeClick(preference) } @@ -87,15 +81,15 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { private fun onUpdateAvailable(version: AppVersion?) { if (version == null) { Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show() - return + } else { + startActivity(Intent(requireContext(), AppUpdateActivity::class.java)) } - startActivity(Intent(requireContext(), AppUpdateActivity::class.java)) } - private fun openLink(url: String, title: CharSequence?) { + private fun openLink(@StringRes url: Int, title: CharSequence?): Boolean { val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() - startActivitySafe( + intent.data = getString(url).toUri() + return startActivitySafe( if (title != null) { Intent.createChooser(intent, title) } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index 2d6d771e4..cb0ccd202 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider +import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity import org.koitharu.kotatsu.tracker.work.TrackerNotificationHelper import javax.inject.Inject @@ -116,6 +117,11 @@ class TrackerSettingsFragment : true } + AppSettings.KEY_TRACKER_DEBUG -> { + startActivity(Intent(preference.context, TrackerDebugActivity::class.java)) + true + } + else -> super.onPreferenceTreeClick(preference) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt deleted file mode 100644 index ed9aabd94..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.util.AttributeSet -import android.view.View -import androidx.appcompat.widget.TooltipCompat -import androidx.core.net.toUri -import androidx.core.view.forEach -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding - -class AboutLinksPreference @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : Preference(context, attrs), View.OnClickListener { - - init { - layoutResource = R.layout.preference_about_links - isSelectable = false - isPersistent = false - } - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - val binding = PreferenceAboutLinksBinding.bind(holder.itemView) - binding.root.forEach { button -> - TooltipCompat.setTooltipText(button, button.contentDescription) - button.setOnClickListener(this) - } - } - - override fun onClick(v: View) { - val urlResId = when (v.id) { - R.id.btn_discord -> R.string.url_discord - R.id.btn_telegram -> R.string.url_telegram - R.id.btn_github -> R.string.url_github - else -> return - } - openLink(v, v.context.getString(urlResId), v.contentDescription) - } - - private fun openLink(v: View, url: String, title: CharSequence?) { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - try { - context.startActivity( - if (title != null) { - Intent.createChooser(intent, title) - } else { - intent - }, - ) - } catch (_: ActivityNotFoundException) { - Snackbar.make(v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index ae68969e0..3aa5959c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -10,6 +10,7 @@ import android.content.SyncResult import android.content.SyncStats import android.database.Cursor import android.net.Uri +import android.util.Log import androidx.annotation.WorkerThread import androidx.core.content.contentValuesOf import dagger.assisted.Assisted @@ -18,9 +19,9 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response import org.json.JSONArray import org.json.JSONObject +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES @@ -28,10 +29,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS -import org.koitharu.kotatsu.core.logs.FileLogger -import org.koitharu.kotatsu.core.logs.SyncLogger import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toContentValues import org.koitharu.kotatsu.core.util.ext.toJson import org.koitharu.kotatsu.core.util.ext.toRequestBody @@ -50,7 +50,6 @@ class SyncHelper @AssistedInject constructor( @Assisted private val account: Account, @Assisted private val provider: ContentProviderClient, private val settings: SyncSettings, - @SyncLogger private val logger: FileLogger, ) { private val authorityHistory = context.getString(R.string.sync_authority_history) @@ -75,7 +74,7 @@ class SyncHelper @AssistedInject constructor( .url("$baseUrl/resource/$TABLE_FAVOURITES") .post(data.toRequestBody()) .build() - val response = httpClient.newCall(request).execute().log().parseJsonOrNull() + val response = httpClient.newCall(request).execute().parseJsonOrNull() if (response != null) { val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES)) stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L @@ -97,7 +96,7 @@ class SyncHelper @AssistedInject constructor( .url("$baseUrl/resource/$TABLE_HISTORY") .post(data.toRequestBody()) .build() - val response = httpClient.newCall(request).execute().log().parseJsonOrNull() + val response = httpClient.newCall(request).execute().parseJsonOrNull() if (response != null) { val result = upsertHistory( json = response.getJSONArray(TABLE_HISTORY), @@ -110,15 +109,12 @@ class SyncHelper @AssistedInject constructor( } fun onError(e: Throwable) { - if (logger.isEnabled) { - logger.log("Sync error", e) - } + e.printStackTraceDebug() } fun onSyncComplete(result: SyncResult) { - if (logger.isEnabled) { - logger.log("Sync finished: ${result.toDebugString()}") - logger.flushBlocking() + if (BuildConfig.DEBUG) { + Log.i("Sync", "Sync finished: ${result.toDebugString()}") } } @@ -298,12 +294,6 @@ class SyncHelper @AssistedInject constructor( private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray - private fun Response.log() = apply { - if (logger.isEnabled) { - logger.log("$code ${request.url}") - } - } - @AssistedFactory interface Factory { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index ddb9a9b8d..0c2cc96c8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -45,13 +45,12 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.logs.FileLogger -import org.koitharu.kotatsu.core.logs.TrackerLogger import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.onEachIndexed +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalMangaRepository @@ -80,7 +79,6 @@ class TrackWorker @AssistedInject constructor( private val getTracksUseCase: GetTracksUseCase, private val checkNewChaptersUseCase: CheckNewChaptersUseCase, private val workManager: WorkManager, - @TrackerLogger private val logger: FileLogger, private val localRepositoryLazy: Lazy, private val downloadSchedulerLazy: Lazy, ) : CoroutineWorker(context, workerParams) { @@ -90,17 +88,15 @@ class TrackWorker @AssistedInject constructor( override suspend fun doWork(): Result { notificationHelper.updateChannels() val isForeground = trySetForeground() - logger.log("doWork(): attempt $runAttemptCount") return try { doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags) } catch (e: CancellationException) { throw e } catch (e: Throwable) { - logger.log("fatal", e) + e.printStackTraceDebug() Result.failure() } finally { withContext(NonCancellable) { - logger.flush() notificationManager.cancel(WORKER_NOTIFICATION_ID) } } @@ -111,7 +107,6 @@ class TrackWorker @AssistedInject constructor( return Result.success() } val tracks = getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE) - logger.log("Total ${tracks.size} tracks") if (tracks.isEmpty()) { return Result.success() } @@ -154,7 +149,6 @@ class TrackWorker @AssistedInject constructor( when (it) { is MangaUpdates.Failure -> { val e = it.error - logger.log("checkUpdatesAsync", e) if (e is CloudFlareProtectedException) { CaptchaNotifier(applicationContext).notify(e) } diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index c69fc6be3..c1db553ce 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -1,9 +1,10 @@ https://github.com/KotatsuApp/Kotatsu - https://discord.gg/NNJ5RgVBC5 - https://t.me/kotatsuapp + https://t.me/kotatsuapp + tg://resolve?domain=kotatsuapp https://hosted.weblate.org/engage/kotatsu + https://kotatsu.app/manuals/guides/getting-started/ https://bugs.kotatsu.app/report org.kotatsu.sync https://sync.kotatsu.app diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2648670ac..e0cca68e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -733,4 +733,8 @@ Image set Artist CG Game CG + Debug + Source code + User manual + Telegram group diff --git a/app/src/main/res/xml/pref_about.xml b/app/src/main/res/xml/pref_about.xml index 2ce94cb0c..aa3e7c27f 100644 --- a/app/src/main/res/xml/pref_about.xml +++ b/app/src/main/res/xml/pref_about.xml @@ -3,45 +3,40 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> - + - + - + - + - + - - - - - - - + diff --git a/app/src/main/res/xml/pref_tracker.xml b/app/src/main/res/xml/pref_tracker.xml index 71b113a1e..b6b4bdae1 100644 --- a/app/src/main/res/xml/pref_tracker.xml +++ b/app/src/main/res/xml/pref_tracker.xml @@ -1,8 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> - + - + + + + + + From 27d7a6a8cb1b8b02da923824bb12edc6a6af956b Mon Sep 17 00:00:00 2001 From: Mac135135 Date: Sat, 28 Sep 2024 19:17:12 +0300 Subject: [PATCH 58/71] Added an periodical backup to the telegram bot --- .../main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt | 1 - .../kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt | 2 -- .../koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 84a7c2db3..10fd980f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -43,7 +43,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val connectivityManager = context.connectivityManager private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - var telegramChatId: String? get() = preferences.getString("telegram_chat_id", null) set(value) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index cb8ec0267..164f76312 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -52,7 +52,6 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi openTelegramBot("kotatsu_backup_bot") true } - // Кнопка для проверки работы API val checkApiButton = Preference(requireContext()).apply { key = "check_api_working" title = "Проверить работу API" @@ -102,7 +101,6 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi telegramIntent.setPackage("org.telegram.messenger") startActivity(telegramIntent) } catch (e: Exception) { - // Если Telegram не установлен, открываем через браузер val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$botUsername")) startActivity(browserIntent) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt index e585b2f03..33a352b4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -151,3 +151,4 @@ class PeriodicalBackupWorker @AssistedInject constructor( const val DATA_TIMESTAMP = "ts" } } + From 49d29ae675107040f04d9fb46c4f1b1aebcc44b9 Mon Sep 17 00:00:00 2001 From: Mac135135 Date: Sat, 28 Sep 2024 19:20:25 +0300 Subject: [PATCH 59/71] Added an periodical backup to the telegram bot --- .../main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt | 1 + .../kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt | 1 + .../koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt | 1 - app/src/main/res/xml/pref_backup_periodic.xml | 1 - 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 10fd980f6..84a7c2db3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -43,6 +43,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val connectivityManager = context.connectivityManager private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + var telegramChatId: String? get() = preferences.getString("telegram_chat_id", null) set(value) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index 164f76312..17ded15be 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -194,3 +194,4 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi return resolveFile(context)?.path ?: toString() } } + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt index 33a352b4f..e585b2f03 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -151,4 +151,3 @@ class PeriodicalBackupWorker @AssistedInject constructor( const val DATA_TIMESTAMP = "ts" } } - diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index db089196b..e6664e262 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -43,5 +43,4 @@ android:defaultValue="" android:summary="Enter the chat ID where backups should be sent" /> - From 6a54d42867114acd36ab3ba99762c520c11a49c3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 29 Sep 2024 19:43:09 +0300 Subject: [PATCH 60/71] Update SSIV --- app/build.gradle | 8 ++++---- .../org/koitharu/kotatsu/core/util/ext/Throwable.kt | 2 ++ .../kotatsu/settings/sources/catalog/SourceCatalogPage.kt | 2 +- .../kotatsu/settings/utils/PercentSummaryProvider.kt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 760c4440c..0ba441866 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 673 - versionName = '7.6' + versionCode = 674 + versionName = '7.6.1' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -83,7 +83,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:3cdd391410') { + implementation('com.github.KotatsuApp:kotatsu-parsers:1.1') { exclude group: 'org.json', module: 'json' } @@ -137,7 +137,7 @@ dependencies { implementation 'io.coil-kt:coil-base:2.7.0' implementation 'io.coil-kt:coil-svg:2.7.0' - implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962' + implementation 'com.github.KotatsuApp:subsampling-scale-image-view:b2c5a6d5ca' implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'io.noties.markwon:core:4.6.2' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 060703b77..93c447518 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException import android.content.res.Resources import androidx.annotation.DrawableRes import coil.network.HttpException +import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import okio.FileNotFoundException import okio.IOException import okio.ProtocolException @@ -80,6 +81,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { is UnknownHostException, is SocketTimeoutException -> resources.getString(R.string.network_error) + is ImageDecodeException -> resources.getString(R.string.error_corrupted_file) is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible) is WrongPasswordException -> resources.getString(R.string.wrong_password) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt index b129022db..55ca78aa3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt @@ -13,7 +13,7 @@ data class SourceCatalogPage( return other is SourceCatalogPage && other.type == type } - override fun getChangePayload(previousState: ListModel): Any? { + override fun getChangePayload(previousState: ListModel): Any { return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PercentSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PercentSummaryProvider.kt index 66e05e261..82b9ad561 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PercentSummaryProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PercentSummaryProvider.kt @@ -7,7 +7,7 @@ class PercentSummaryProvider : Preference.SummaryProvider { private var percentPattern: String? = null - override fun provideSummary(preference: SliderPreference): CharSequence? { + override fun provideSummary(preference: SliderPreference): CharSequence { val pattern = percentPattern ?: preference.context.getString(R.string.percent_string_pattern).also { percentPattern = it } From f38370592e15257f80c3a44623ab93f861cf11ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Ersen?= Date: Sat, 28 Sep 2024 13:16:27 +0000 Subject: [PATCH 61/71] Translated using Weblate (Turkish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (728 of 728 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (724 of 724 strings) Co-authored-by: Oğuz Ersen Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/ Translation: Kotatsu/Strings --- app/src/main/res/values-tr/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9fece70e5..8de84ae26 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -715,4 +715,12 @@ Bu kaynak filtrelerle aramayı desteklemiyor. Filtreleriniz temizlendi Kodomo Bir kerelik + Resim kümesi + Doujinshi + Sanatçı CG + Oyun CG + Hata ayıkla + Kullanıcı kılavuzu + Kaynak kodu + Telegram grubu \ No newline at end of file From 9be0e8595fd09fd10b404a27ddbc5c65aa011d5e Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 28 Sep 2024 13:16:29 +0000 Subject: [PATCH 62/71] Translated using Weblate (Japanese) Currently translated at 100.0% (9 of 9 strings) Co-authored-by: Matt Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/ Translation: Kotatsu/plurals --- app/src/main/res/values-ja/plurals.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-ja/plurals.xml b/app/src/main/res/values-ja/plurals.xml index 3a35d014d..0f9261aca 100644 --- a/app/src/main/res/values-ja/plurals.xml +++ b/app/src/main/res/values-ja/plurals.xml @@ -21,4 +21,10 @@ %1$d ヶ月前 + + %1$d時間 + + + %1$d分 + \ No newline at end of file From be3b5a1897c75fe707889c1700856b4baed18cbb Mon Sep 17 00:00:00 2001 From: Draken Date: Sat, 28 Sep 2024 13:16:34 +0000 Subject: [PATCH 63/71] Translated using Weblate (Vietnamese) Currently translated at 100.0% (728 of 728 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (724 of 724 strings) Co-authored-by: Draken Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/ Translation: Kotatsu/Strings --- app/src/main/res/values-vi/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b47c26e48..54702fed8 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -715,4 +715,12 @@ Bộ lọc của bạn đã bị xóa do nguồn đọc này không hỗ trợ cho việc tìm kiếm bằng bộ lọc Dành cho trẻ em One shot + Đặt hình ảnh + Game CG + Doujinshi + Họa sĩ CG + Mã nguồn + Hướng dẫn sử dụng + Nhóm Telegram + Gỡ lỗi \ No newline at end of file From 150e3d554f03f58442e11b22527ef3d18c60e41c Mon Sep 17 00:00:00 2001 From: Felipe Nascimento Date: Sat, 28 Sep 2024 13:16:36 +0000 Subject: [PATCH 64/71] Translated using Weblate (Portuguese) Currently translated at 98.6% (718 of 728 strings) Co-authored-by: Felipe Nascimento Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/ Translation: Kotatsu/Strings --- app/src/main/res/values-pt/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8bd3b6c58..2b50f8366 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -705,4 +705,11 @@ Ascendente Data Popularidade + Artista CG + Depurar + Código fonte + Manual do usuário + Grupo Telegram + Conjunto de imagens + Jogo CG \ No newline at end of file From 8e8953b07f381ce4485fceeb3ea5c01b778e0415 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 29 Sep 2024 19:46:42 +0300 Subject: [PATCH 65/71] Skip error for local manga list (close #1113, close #1115) --- .../koitharu/kotatsu/local/data/LocalMangaRepository.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index f2c6ae588..9b99de67a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -230,9 +230,12 @@ class LocalMangaRepository @Inject constructor( val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) for (file in files) { launch(dispatcher) { - val m = LocalMangaInput.ofOrNull(file)?.getManga() - if (m != null) { - send(m) + runCatchingCancellable { + LocalMangaInput.ofOrNull(file)?.getManga() + }.onFailure { e -> + e.printStackTraceDebug() + }.onSuccess { m -> + if (m != null) send(m) } } } From 44349c4edee474a3f5d7808eafa325fc7f26da7b Mon Sep 17 00:00:00 2001 From: Kristian de Frutos Date: Tue, 14 May 2024 11:47:50 +0000 Subject: [PATCH 66/71] Translated using Weblate (Czech) Currently translated at 100.0% (636 of 636 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (636 of 636 strings) Translated using Weblate (Czech) Currently translated at 83.0% (528 of 636 strings) Translated using Weblate (Czech) Currently translated at 100.0% (9 of 9 strings) Co-authored-by: Kristian de Frutos Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/cs/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/ Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/ Translation: Kotatsu/Strings Translation: Kotatsu/plurals --- app/src/main/res/values-cs/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 2714f5ad4..243c76e31 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -644,4 +644,4 @@ Vypnout NSFW oznámení Log kontroly nových kapitol Debug informace o kontrole nových kapitol na pozadí - \ No newline at end of file + From 1cff0eeac43453d0afb3233fb8b2335523e22939 Mon Sep 17 00:00:00 2001 From: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> Date: Thu, 23 May 2024 13:17:39 +0500 Subject: [PATCH 67/71] implement basic methods for descrambling images --- .../kotatsu/core/parser/BitmapImpl.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt new file mode 100644 index 000000000..b55e8333d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.core.parser + +import android.graphics.Canvas +import android.graphics.Bitmap as AndroidBitmap +import android.graphics.Rect as AndroidRect +import org.koitharu.kotatsu.parsers.bitmap.Bitmap +import org.koitharu.kotatsu.parsers.bitmap.Rect + +class BitmapImpl private constructor() : Bitmap { + + lateinit var androidBitmap: AndroidBitmap + + private lateinit var canvas: Canvas + + override val height: Int + get() = androidBitmap.height + + override val width: Int + get() = androidBitmap.width + + override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) { + val androidSourceBitmap = (sourceBitmap as BitmapImpl).androidBitmap + + canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null) + } + + companion object { + fun create(width: Int, height: Int): Bitmap { + val instance = BitmapImpl() + instance.androidBitmap = AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888) + instance.canvas = Canvas(instance.androidBitmap) + + return instance + } + + fun create(bitmap: AndroidBitmap): Bitmap { + val instance = BitmapImpl() + instance.androidBitmap = bitmap.copy(AndroidBitmap.Config.ARGB_8888, true) + instance.canvas = Canvas(instance.androidBitmap) + + return instance + } + } +} + +private fun Rect.toAndroidRect(): AndroidRect { + return AndroidRect(left, top, right, bottom) +} From 09b6a967a1a9cc43f3102fb336f48a84fdaf6b31 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 23 May 2024 16:55:41 +0300 Subject: [PATCH 68/71] Refactor descrambling bitmap --- .../kotatsu/core/parser/BitmapImpl.kt | 48 ------------------- .../koitharu/kotatsu/core/util/ext/Http.kt | 3 ++ 2 files changed, 3 insertions(+), 48 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt deleted file mode 100644 index b55e8333d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/BitmapImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import android.graphics.Canvas -import android.graphics.Bitmap as AndroidBitmap -import android.graphics.Rect as AndroidRect -import org.koitharu.kotatsu.parsers.bitmap.Bitmap -import org.koitharu.kotatsu.parsers.bitmap.Rect - -class BitmapImpl private constructor() : Bitmap { - - lateinit var androidBitmap: AndroidBitmap - - private lateinit var canvas: Canvas - - override val height: Int - get() = androidBitmap.height - - override val width: Int - get() = androidBitmap.width - - override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) { - val androidSourceBitmap = (sourceBitmap as BitmapImpl).androidBitmap - - canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null) - } - - companion object { - fun create(width: Int, height: Int): Bitmap { - val instance = BitmapImpl() - instance.androidBitmap = AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888) - instance.canvas = Canvas(instance.androidBitmap) - - return instance - } - - fun create(bitmap: AndroidBitmap): Bitmap { - val instance = BitmapImpl() - instance.androidBitmap = bitmap.copy(AndroidBitmap.Config.ARGB_8888, true) - instance.canvas = Canvas(instance.androidBitmap) - - return instance - } - } -} - -private fun Rect.toAndroidRect(): AndroidRect { - return AndroidRect(left, top, right, bottom) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt index 5d384ba88..018d594e1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt @@ -5,6 +5,7 @@ import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okhttp3.ResponseBody import okhttp3.internal.closeQuietly import okio.IOException import org.json.JSONObject @@ -40,6 +41,8 @@ fun Response.ensureSuccess() = apply { } } +fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" } + fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> c.name(name) c.value(value) From dc45e0f5dfb51d4f6ff97335dd98f9f28d18b444 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 27 May 2024 17:29:31 +0300 Subject: [PATCH 69/71] Revert "Update sources catalog ui" This reverts commit 597ad01e8fd2e7bd2736296e37f6e46299b99981. --- .../kotatsu/core/db/migrations/Migration20To21.kt | 12 ------------ .../org/koitharu/kotatsu/tracker/data/TrackEntity.kt | 2 -- .../kotatsu/tracker/domain/TrackingRepository.kt | 3 --- 3 files changed, 17 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt deleted file mode 100644 index 462b77261..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration20To21 : Migration(20, 21) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt index 127a60b4c..c152253ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt @@ -25,7 +25,6 @@ class TrackEntity( @ColumnInfo(name = "last_check_time") val lastCheckTime: Long, @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long, @ColumnInfo(name = "last_result") val lastResult: Int, - @ColumnInfo(name = "last_error") val lastError: String?, ) { companion object { @@ -43,7 +42,6 @@ class TrackEntity( lastCheckTime = 0L, lastChapterDate = 0, lastResult = RESULT_NONE, - lastError = null, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 58bd10e95..e4f60dee0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -168,7 +168,6 @@ class TrackingRepository @Inject constructor( lastCheckTime = tracking.lastCheck?.toEpochMilli() ?: 0L, lastChapterDate = tracking.lastChapterDate?.toEpochMilli() ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, - lastError = null, ) db.getTracksDao().upsert(entity) } @@ -225,7 +224,6 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, lastResult = TrackEntity.RESULT_FAILED, - lastError = updates.error?.toString(), ) is MangaUpdates.Success -> TrackEntity( @@ -235,7 +233,6 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, - lastError = null, ) } } From 15ca4111c0376373e21ed7f259eb2e7e0f1f5b6d Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 1 Jun 2024 11:55:52 +0300 Subject: [PATCH 70/71] Reapply "Update sources catalog ui" This reverts commit 8d5bde6e60cca4a7d484a0b01856aac3dda88c34. --- .../kotatsu/core/db/migrations/Migration20To21.kt | 12 ++++++++++++ .../org/koitharu/kotatsu/tracker/data/TrackEntity.kt | 2 ++ .../kotatsu/tracker/domain/TrackingRepository.kt | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt new file mode 100644 index 000000000..462b77261 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration20To21.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration20To21 : Migration(20, 21) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt index c152253ac..127a60b4c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt @@ -25,6 +25,7 @@ class TrackEntity( @ColumnInfo(name = "last_check_time") val lastCheckTime: Long, @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long, @ColumnInfo(name = "last_result") val lastResult: Int, + @ColumnInfo(name = "last_error") val lastError: String?, ) { companion object { @@ -42,6 +43,7 @@ class TrackEntity( lastCheckTime = 0L, lastChapterDate = 0, lastResult = RESULT_NONE, + lastError = null, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index e4f60dee0..58bd10e95 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -168,6 +168,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = tracking.lastCheck?.toEpochMilli() ?: 0L, lastChapterDate = tracking.lastChapterDate?.toEpochMilli() ?: 0L, lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION, + lastError = null, ) db.getTracksDao().upsert(entity) } @@ -224,6 +225,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, lastResult = TrackEntity.RESULT_FAILED, + lastError = updates.error?.toString(), ) is MangaUpdates.Success -> TrackEntity( @@ -233,6 +235,7 @@ class TrackingRepository @Inject constructor( lastCheckTime = System.currentTimeMillis(), lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, + lastError = null, ) } } From 3ef7c6adb01c6ac70cf915f6fc7c575fd00b9c09 Mon Sep 17 00:00:00 2001 From: Mac135135 Date: Sun, 10 Nov 2024 15:11:40 +0300 Subject: [PATCH 71/71] Added an periodical backup to the telegram bot --- .../core/backup/ExternalBackupStorage.kt | 40 +++++++++++++++++++ .../backup/PeriodicalBackupService.kt | 5 ++- .../PeriodicalBackupSettingsFragment.kt | 22 +++++----- app/src/main/res/values/strings.xml | 8 ++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt index ccef8b64d..b625241eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt @@ -7,6 +7,12 @@ import androidx.documentfile.provider.DocumentFile import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody import okio.buffer import okio.sink import okio.source @@ -15,6 +21,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File +import java.io.IOException import javax.inject.Inject class ExternalBackupStorage @Inject constructor( @@ -86,3 +93,36 @@ class ExternalBackupStorage @Inject constructor( return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" } } } +class TelegramBackupUploader @Inject constructor(private val settings: AppSettings) { + + private val client = OkHttpClient() + + suspend fun uploadBackupToTelegram(file: File) = withContext(Dispatchers.IO) { + val botToken = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM" + val chatId = settings.telegramChatId + + if (botToken.isNullOrEmpty() || chatId.isNullOrEmpty()) { + throw IllegalStateException("Telegram API key or chat ID not set in settings.") + } + + val mediaType = "application/zip".toMediaTypeOrNull() + val requestBody = file.asRequestBody(mediaType) + + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("chat_id", chatId) + .addFormDataPart("document", file.name, requestBody) + .build() + + val request = Request.Builder() + .url("https://api.telegram.org/bot$botToken/sendDocument") + .post(multipartBody) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Failed to send backup to Telegram: ${response.message}") + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt index 81602e128..23205c433 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.CoroutineIntentService import javax.inject.Inject @@ -14,7 +15,8 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage - + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader @Inject lateinit var repository: BackupRepository @@ -43,6 +45,7 @@ class PeriodicalBackupService : CoroutineIntentService() { } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) + telegramBackupUploader.uploadBackupToTelegram(output.file) } finally { output.file.delete() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index d7bee53d3..416fd4247 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -56,16 +56,14 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi } val checkApiButton = Preference(requireContext()).apply { key = "check_api_working" - title = "Проверить работу API" - summary = "Нажмите для проверки работы Telegram Bot API" + title = context.getString(R.string.api_telegram_check) + summary = context.getString(R.string.api_check_desc) } checkApiButton.setOnPreferenceClickListener { - val apiKey = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM" // Получите API Key из настроек + val apiKey = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM" if (apiKey.isNotEmpty()) { checkTelegramBotApiKey(apiKey) - } else { - Toast.makeText(requireContext(), "Введите API Key в настройках!", Toast.LENGTH_SHORT).show() } true } @@ -84,14 +82,14 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi override fun onResponse(call: Call, response: Response) { requireActivity().runOnUiThread { if (response.isSuccessful) { - sendMessageToTelegram(apiKey, "Kotatsu's backup in Telegram is working!!") + context?.let { sendMessageToTelegram(apiKey, it.getString(R.string.api_is_work)) } } - } + } } override fun onFailure(call: Call, e: IOException) { requireActivity().runOnUiThread { - Toast.makeText(requireContext(), "Network error! Check your Net", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.api_net_error, Toast.LENGTH_SHORT).show() } } }) @@ -110,7 +108,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi private fun sendMessageToTelegram(apiKey: String, message: String) { val chatId = settings.telegramChatId if (chatId.isNullOrEmpty()) { - Toast.makeText(requireContext(), "Chat ID is not set!", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.id_not_set, Toast.LENGTH_SHORT).show() return } @@ -124,16 +122,16 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi override fun onResponse(call: Call, response: Response) { requireActivity().runOnUiThread { if (response.isSuccessful) { - Toast.makeText(requireContext(), "Success! Check Telegram Bot", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.api_check_success, Toast.LENGTH_SHORT).show() } else { - Toast.makeText(requireContext(), "OOPS! Something went wrong", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.api_check_error, Toast.LENGTH_SHORT).show() } } } override fun onFailure(call: Call, e: IOException) { requireActivity().runOnUiThread { - Toast.makeText(requireContext(), "Network error!", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.api_error, Toast.LENGTH_SHORT).show() } } }) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6eb2d8e4a..5c4dadab1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -763,4 +763,12 @@ "]]> Access denied (403) Max number of backups + Check API work + Click to check the operation of the Telegram Bot API + Kotatsu backup in Telegram is working!! + Network error! Check your Net + Chat ID is not set! + Success! Check Telegram Bot + OOPS! Something went wrong + Network error!