Compare commits
1 Commits
v7.2
...
ui_playgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4f18066 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠️ Source issue
|
||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
required: true
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 648
|
||||
versionName = '7.2'
|
||||
versionCode = 642
|
||||
versionName = '7.0.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:56fd22b43f') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -90,15 +90,14 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -106,7 +105,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.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
|
||||
12
app/src/debug/AndroidManifest.xml
Normal file
12
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
|
||||
<activity
|
||||
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/check_for_new_chapters" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
context.startActivity(WorkInspector.getIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnList
|
||||
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
||||
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = tracksAdapter
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
|
||||
android:layout_marginBottom="16dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
@@ -8,9 +8,14 @@
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@id/action_tracker"
|
||||
android:title="@string/check_for_new_chapters"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@id/action_works"
|
||||
android:title="@string/wi_lib_name"
|
||||
android:title="Works"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
@@ -100,13 +100,6 @@
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.READ_MANGA" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||
android:resource="@xml/remote_action" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||
@@ -255,9 +248,6 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
|
||||
android:label="@string/app_update_available" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/tracker_debug_info" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -69,7 +69,6 @@ class MigrateUseCase @Inject constructor(
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
lastError = null,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
|
||||
@@ -108,10 +108,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (hasViewBinding()) {
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
|
||||
@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
@@ -48,7 +48,6 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||
@@ -153,12 +152,10 @@ interface AppModule {
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
acraScreenLogger: AcraScreenLogger,
|
||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
acraScreenLogger,
|
||||
screenshotPolicyHelper,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -84,7 +84,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
source = json.getString("source"),
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
|
||||
@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
||||
|
||||
private val isLowRam = application.isLowRamDevice()
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache =
|
||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache =
|
||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -59,7 +58,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 21
|
||||
const val DATABASE_VERSION = 20
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -119,7 +118,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
|
||||
@@ -24,9 +23,6 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||
abstract suspend fun findAllEnabledNames(): List<String>
|
||||
|
||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@@ -72,7 +68,6 @@ abstract class MangaSourcesDao {
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
|
||||
@@ -14,5 +14,4 @@ data class MangaSourceEntity(
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,36 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||
private val activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||
|
||||
val context: Context?
|
||||
get() = activity ?: fragment?.context
|
||||
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
@@ -68,12 +56,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
@@ -98,37 +80,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
context?.run {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
context?.run {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = context ?: return
|
||||
val settings = getAppSettings(ctx)
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.ignore_ssl_errors)
|
||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||
ctx.findActivity()?.finishAffinity()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getAppSettings(context: Context): AppSettings {
|
||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
@@ -141,9 +99,6 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
@@ -37,7 +39,7 @@ val ContentType.titleResId
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
val locale = locale?.toLocale().getDisplayName(context)
|
||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Interceptor.Chain
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -15,7 +13,6 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.IDN
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -26,7 +23,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Chain): Response {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
@@ -49,7 +46,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
||||
}
|
||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||
return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||
@@ -58,21 +55,10 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||
intercept(chain)
|
||||
}.getOrElse { e ->
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
// only IOException can be safely thrown from an Interceptor
|
||||
throw IOException("Error in interceptor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private class ProxyChain(
|
||||
private val delegate: Chain,
|
||||
private val delegate: Interceptor.Chain,
|
||||
private val request: Request,
|
||||
) : Chain by delegate {
|
||||
) : Interceptor.Chain by delegate {
|
||||
|
||||
override fun request(): Request = request
|
||||
}
|
||||
|
||||
@@ -83,11 +83,6 @@ class DoHManager(
|
||||
tryGetByIp("2a10:50c0::2:ff"),
|
||||
),
|
||||
).build()
|
||||
|
||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
||||
.resolvePublicAddresses(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||
|
||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
enum class DoHProvider {
|
||||
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
|
||||
}
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
val result = chain.proceed(newRequest)
|
||||
return if (result is SuccessResult) {
|
||||
result
|
||||
} else {
|
||||
logDebug((result as? ErrorResult)?.throwable)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return okHttp.newCall(request).await()
|
||||
}
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val newRequest = request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover {
|
||||
logDebug(it)
|
||||
okHttp.doCall(request).also {
|
||||
blacklist.add(sourceUrl.host)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable?) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
@@ -31,9 +29,6 @@ interface NetworkModule {
|
||||
@Binds
|
||||
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
||||
|
||||
@Binds
|
||||
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.network.HttpException
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.Collections
|
||||
|
||||
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newRequest = onInterceptImageRequest(request, url)
|
||||
return when (val result = chain.proceed(newRequest)) {
|
||||
is SuccessResult -> result
|
||||
is ErrorResult -> {
|
||||
logDebug(result.throwable, newRequest.data)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
val newRequest = onInterceptPageRequest(request)
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover { error ->
|
||||
logDebug(error, newRequest.url)
|
||||
okHttp.doCall(request).also {
|
||||
if (error.isBlockedByServer()) {
|
||||
blacklist.add(request.url.host)
|
||||
}
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
|
||||
|
||||
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable, url: Any) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", "${e.message}: $url", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isBlockedByServer(): Boolean {
|
||||
return this is CloudFlareBlockedException
|
||||
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
interface ImageProxyInterceptor : Interceptor {
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.plus
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : ImageProxyInterceptor {
|
||||
|
||||
private val delegate = settings.observeAsStateFlow(
|
||||
scope = processLifecycleScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_IMAGES_PROXY,
|
||||
valueProducer = { createDelegate() },
|
||||
)
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
|
||||
}
|
||||
|
||||
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
|
||||
}
|
||||
|
||||
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
|
||||
-1 -> null
|
||||
0 -> WsrvNlProxyInterceptor()
|
||||
1 -> ZeroMsProxyInterceptor()
|
||||
else -> error("Unsupported images proxy $proxy")
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
return request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
return request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
|
||||
return request
|
||||
}
|
||||
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.data(newUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.graphics.Canvas
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||
import java.io.OutputStream
|
||||
import android.graphics.Bitmap as AndroidBitmap
|
||||
import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap,
|
||||
) : Bitmap {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||
|
||||
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 BitmapWrapper).androidBitmap
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||
)
|
||||
|
||||
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
@@ -11,21 +10,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
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.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
|
||||
@@ -75,27 +68,6 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
|
||||
@@ -26,7 +26,6 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
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
|
||||
@@ -151,6 +150,10 @@ class FaviconFetcher(
|
||||
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
||||
}
|
||||
|
||||
private fun Response.requireBody(): ResponseBody {
|
||||
return checkNotNull(body) { "response body == null" }
|
||||
}
|
||||
|
||||
private fun Size.toCacheKey() = buildString {
|
||||
append(width.toString())
|
||||
append('x')
|
||||
|
||||
@@ -155,9 +155,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isTrackerNotificationsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||
|
||||
val isTrackerNsfwDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||
|
||||
var notificationSound: Uri
|
||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
@@ -290,15 +287,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
||||
|
||||
var sourcesVersion: Int
|
||||
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
|
||||
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
|
||||
val isNewSourcesTipEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
val screenshotsPolicy: ScreenshotsPolicy
|
||||
get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW)
|
||||
get() = runCatching {
|
||||
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
|
||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
||||
|
||||
var userSpecifiedMangaDirectories: Set<File>
|
||||
get() {
|
||||
@@ -381,18 +380,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val imagesProxy: Int
|
||||
get() {
|
||||
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
|
||||
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
|
||||
}
|
||||
val isImagesProxyEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
||||
|
||||
val dnsOverHttps: DoHProvider
|
||||
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
||||
|
||||
var isSSLBypassEnabled: Boolean
|
||||
val isSSLBypassEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
|
||||
|
||||
val proxyType: Proxy.Type
|
||||
get() {
|
||||
@@ -552,6 +547,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
||||
|
||||
const val TRACK_HISTORY = "history"
|
||||
const val TRACK_FAVOURITES = "favourites"
|
||||
|
||||
@@ -588,7 +585,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||
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_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
@@ -601,6 +597,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_ZOOM_MODE = "zoom_mode"
|
||||
const val KEY_BACKUP = "backup"
|
||||
const val KEY_RESTORE = "restore"
|
||||
@@ -650,7 +647,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_SOURCES_NEW = "sources_new"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
@@ -663,7 +662,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_AUTH = "proxy_auth"
|
||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy_2"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||
const val KEY_RELATED_MANGA = "related_manga"
|
||||
@@ -677,6 +676,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||
@@ -684,19 +684,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
const val KEY_SOURCES_VERSION = "sources_version"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
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"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
|
||||
enum class ScreenshotsPolicy {
|
||||
|
||||
// Do not rename this
|
||||
ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL;
|
||||
}
|
||||
ALLOW, BLOCK_NSFW, BLOCK_ALL;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.prefs
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
@@ -11,7 +12,6 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
@@ -31,11 +31,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
.ifNullOrEmpty { key.defaultValue }
|
||||
.sanitizeHeaderValue()
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue)
|
||||
?.trim()
|
||||
?.takeIf { DomainValidator.isValidDomain(it) }
|
||||
?: key.defaultValue
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
} as T
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -19,8 +18,6 @@ import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
@@ -28,12 +25,10 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ScreenshotPolicyHelper.ContentContainer,
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var isAmoledTheme = false
|
||||
@@ -97,20 +92,10 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// TODO fix behavior on Android 14
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
val fm = supportFragmentManager
|
||||
if (fm.isStateSaved) {
|
||||
if (supportFragmentManager.popBackStackImmediate()) {
|
||||
return false
|
||||
}
|
||||
if (fm.backStackEntryCount > 0) {
|
||||
fm.popBackStack()
|
||||
} else {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -155,8 +140,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
@@ -176,8 +159,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
}
|
||||
|
||||
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface BaseActivityEntryPoint {
|
||||
|
||||
@@ -63,7 +63,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun setTitle(title: CharSequence?) {
|
||||
protected fun setTitle(title: CharSequence?) {
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,16 +52,6 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
||||
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
|
||||
}
|
||||
|
||||
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.setPadding(left, top, right, bottom)
|
||||
fastScroller.setPadding(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
super.setPaddingRelative(start, top, end, bottom)
|
||||
fastScroller.setPaddingRelative(start, top, end, bottom)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
fastScroller.attachRecyclerView(this)
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
class BottomSheetCollapseCallback(
|
||||
private val sheet: ViewGroup,
|
||||
private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet),
|
||||
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) {
|
||||
private val behavior: BottomSheetBehavior<*>,
|
||||
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {
|
||||
|
||||
init {
|
||||
behavior.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
|
||||
@SuppressLint("SwitchIntDef")
|
||||
override fun onStateChanged(view: View, state: Int) {
|
||||
when (state) {
|
||||
STATE_EXPANDED,
|
||||
STATE_HALF_EXPANDED -> isEnabled = true
|
||||
|
||||
STATE_COLLAPSED,
|
||||
STATE_HIDDEN -> isEnabled = false
|
||||
}
|
||||
isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED
|
||||
}
|
||||
|
||||
override fun onSlide(p0: View, p1: Float) = Unit
|
||||
@@ -36,11 +24,7 @@ class BottomSheetCollapseCallback(
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() = behavior.handleBackInvoked()
|
||||
|
||||
override fun handleOnBackCancelled() = behavior.cancelBackProgress()
|
||||
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
|
||||
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
|
||||
override fun handleOnBackPressed() {
|
||||
behavior.state = STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import com.google.android.material.chip.ChipGroup
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@@ -26,9 +24,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||
}
|
||||
private val chipOnCloseListener = OnClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
||||
}
|
||||
private val chipStyle: Int
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -52,7 +48,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
if (isInEditMode) {
|
||||
setChips(
|
||||
List(5) {
|
||||
ChipModel(title = "Chip $it")
|
||||
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -103,15 +99,6 @@ class ChipsView @JvmOverloads constructor(
|
||||
chip.isChipIconVisible = true
|
||||
}
|
||||
chip.isChecked = model.isChecked
|
||||
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
|
||||
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||
chip.setCloseIconResource(
|
||||
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
chip.tag = model.data
|
||||
}
|
||||
|
||||
@@ -119,11 +106,12 @@ class ChipsView @JvmOverloads constructor(
|
||||
val chip = Chip(context)
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.isCheckedIconVisible = true
|
||||
chip.isChipIconVisible = false
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
chip.setOnClickListener(chipOnClickListener)
|
||||
chip.isElegantTextHeight = false
|
||||
addView(chip)
|
||||
return chip
|
||||
}
|
||||
@@ -139,12 +127,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
@ColorRes val tint: Int,
|
||||
val title: CharSequence,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val isCheckable: Boolean = false,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
@DrawableRes val icon: Int,
|
||||
val isCheckable: Boolean,
|
||||
val isChecked: Boolean,
|
||||
val data: Any? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleComparator : Comparator<Locale> {
|
||||
|
||||
private val deviceLocales: List<String>
|
||||
|
||||
init {
|
||||
val localeList = LocaleListCompat.getAdjustedDefault()
|
||||
deviceLocales = buildList(localeList.size() + 1) {
|
||||
add("")
|
||||
val set = HashSet<String>(localeList.size() + 1)
|
||||
set.add("")
|
||||
for (locale in localeList) {
|
||||
val lang = locale.language
|
||||
if (set.add(lang)) {
|
||||
add(lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
||||
.map { it.language }
|
||||
.distinct()
|
||||
|
||||
override fun compare(a: Locale, b: Locale): Int {
|
||||
val indexA = deviceLocales.indexOf(a.language)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.HttpStatusException
|
||||
@@ -41,8 +42,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)
|
||||
|
||||
@@ -22,10 +22,11 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
|
||||
|
||||
fun String.toLocale() = Locale(this)
|
||||
|
||||
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||
null -> context.getString(R.string.all_languages)
|
||||
Locale.ROOT -> context.getString(R.string.various_languages)
|
||||
else -> getDisplayLanguage(this).toTitleCase(this)
|
||||
fun Locale?.getDisplayName(context: Context): String {
|
||||
if (this == null) {
|
||||
return context.getString(R.string.various_languages)
|
||||
}
|
||||
return getDisplayLanguage(this).toTitleCase(this)
|
||||
}
|
||||
|
||||
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
|
||||
|
||||
@@ -25,21 +25,19 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
@@ -136,7 +134,7 @@ class DetailsActivity :
|
||||
viewBinding.buttonRead.setOnClickListener(this)
|
||||
viewBinding.buttonRead.setOnLongClickListener(this)
|
||||
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
|
||||
viewBinding.buttonDownload?.setOnClickListener(this)
|
||||
// viewBinding.buttonDownload?.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSize.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSource.setOnClickListener(this)
|
||||
@@ -154,8 +152,8 @@ class DetailsActivity :
|
||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.chipsTags.onChipClickListener = this
|
||||
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
||||
viewBinding.containerBottomSheet?.let { sheet ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||
viewBinding.layoutBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior))
|
||||
}
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
@@ -199,13 +197,11 @@ class DetailsActivity :
|
||||
addMenuProvider(menuProvider)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
R.id.chip_branch -> showBranchPopupMenu(v)
|
||||
R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider)
|
||||
// R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider)
|
||||
|
||||
R.id.chip_author -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
@@ -412,18 +408,18 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonDownload ?: return
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(R.drawable.ic_download)
|
||||
}
|
||||
// val button = null ?: return
|
||||
// if (isLoading) {
|
||||
// button.setImageDrawable(
|
||||
// CircularProgressDrawable(this).also {
|
||||
// it.setStyle(CircularProgressDrawable.LARGE)
|
||||
// it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
|
||||
// it.start()
|
||||
// },
|
||||
// )
|
||||
// } else {
|
||||
// button.setImageResource(R.drawable.ic_download)
|
||||
// }
|
||||
}
|
||||
|
||||
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
|
||||
@@ -539,7 +535,7 @@ class DetailsActivity :
|
||||
val isFirstCall = buttonRead.tag == null
|
||||
buttonRead.tag = Unit
|
||||
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall)
|
||||
buttonDownload?.isEnabled = info.isValid && info.canDownload
|
||||
// buttonDownload?.isEnabled = info.isValid && info.canDownload
|
||||
buttonRead.isEnabled = info.isValid
|
||||
}
|
||||
|
||||
@@ -616,7 +612,10 @@ class DetailsActivity :
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
|
||||
override fun onViewBindingCreated(binding: SheetChaptersPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
disableFitToContents()
|
||||
binding.headerBar.isVisible = dialog != null
|
||||
|
||||
val args = arguments ?: Bundle.EMPTY
|
||||
var defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab)
|
||||
|
||||
@@ -19,8 +19,8 @@ import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.isFileUri
|
||||
|
||||
@@ -6,22 +6,21 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -30,7 +29,6 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val isNewSourcesAssimilated = AtomicBoolean(false)
|
||||
private val dao: MangaSourcesDao
|
||||
get() = db.getSourcesDao()
|
||||
|
||||
@@ -45,62 +43,25 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val order = settings.sourcesSortOrder
|
||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||
}
|
||||
|
||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
val enabled = dao.findAllEnabledNames()
|
||||
for (name in enabled) {
|
||||
val source = name.toMangaSourceOrNull() ?: continue
|
||||
val source = MangaSource(name)
|
||||
result.remove(source)
|
||||
}
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
result.removeAll { it.isNsfw() }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getAvailableSources(
|
||||
isDisabledOnly: Boolean,
|
||||
isNewOnly: Boolean,
|
||||
excludeBroken: Boolean,
|
||||
types: Set<ContentType>,
|
||||
query: String?,
|
||||
locale: String?,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): List<MangaSource> {
|
||||
assimilateNewSources()
|
||||
val entities = dao.findAll().toMutableList()
|
||||
if (isDisabledOnly) {
|
||||
entities.removeAll { it.isEnabled }
|
||||
}
|
||||
if (isNewOnly) {
|
||||
entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE }
|
||||
}
|
||||
val sources = entities.toSources(
|
||||
skipNsfwSources = settings.isNsfwContentDisabled,
|
||||
sortOrder = sortOrder,
|
||||
)
|
||||
if (locale != null) {
|
||||
sources.retainAll { it.locale == locale }
|
||||
}
|
||||
if (excludeBroken) {
|
||||
sources.removeAll { it.isBroken }
|
||||
}
|
||||
if (types.isNotEmpty()) {
|
||||
sources.retainAll { it.contentType in types }
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
sources.retainAll {
|
||||
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
||||
return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() }
|
||||
return dao.observeIsEnabled(source.name)
|
||||
}
|
||||
|
||||
fun observeEnabledSourcesCount(): Flow<Int> {
|
||||
@@ -108,10 +69,8 @@ class MangaSourcesRepository @Inject constructor(
|
||||
observeIsNsfwDisabled(),
|
||||
dao.observeEnabled(SourcesSortOrder.MANUAL),
|
||||
) { skipNsfw, sources ->
|
||||
sources.count {
|
||||
it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
|
||||
}
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeAvailableSourcesCount(): Flow<Int> {
|
||||
@@ -123,7 +82,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
allMangaSources.count { x ->
|
||||
x.name !in enabled && (!skipNsfw || !x.isNsfw())
|
||||
}
|
||||
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||
@@ -133,18 +92,18 @@ class MangaSourcesRepository @Inject constructor(
|
||||
dao.observeEnabled(order).map {
|
||||
it.toSources(skipNsfw, order)
|
||||
}
|
||||
}.flatMapLatest { it }.onStart { assimilateNewSources() }
|
||||
}.flatMapLatest { it }
|
||||
|
||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||
for (entity in entities) {
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
val source = MangaSource(entity.source)
|
||||
if (source in remoteSources) {
|
||||
result.add(source to entity.isEnabled)
|
||||
}
|
||||
}
|
||||
result
|
||||
}.onStart { assimilateNewSources() }
|
||||
}
|
||||
|
||||
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
|
||||
setSourcesEnabledImpl(sources, isEnabled)
|
||||
@@ -155,7 +114,6 @@ class MangaSourcesRepository @Inject constructor(
|
||||
|
||||
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
||||
db.withTransaction {
|
||||
assimilateNewSources()
|
||||
for (s in remoteSources) {
|
||||
dao.setEnabled(s.name, s in sources)
|
||||
}
|
||||
@@ -177,34 +135,31 @@ class MangaSourcesRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
|
||||
val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty() && sources.size != remoteSources.size
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
|
||||
settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion },
|
||||
observeIsNsfwDisabled(),
|
||||
) { version, skipNsfw ->
|
||||
if (version < BuildConfig.VERSION_CODE) {
|
||||
val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null)
|
||||
sources.isNotEmpty()
|
||||
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
|
||||
if (it) {
|
||||
combine(
|
||||
dao.observeAll(),
|
||||
observeIsNsfwDisabled(),
|
||||
) { entities, skipNsfw ->
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(MangaSource(e.source))
|
||||
}
|
||||
if (skipNsfw) {
|
||||
result.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
result
|
||||
}.distinctUntilChanged()
|
||||
} else {
|
||||
false
|
||||
assimilateNewSources()
|
||||
flowOf(emptySet())
|
||||
}
|
||||
}.onStart { assimilateNewSources() }
|
||||
|
||||
fun clearNewSourcesBadge() {
|
||||
settings.sourcesVersion = BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
private suspend fun assimilateNewSources(): Boolean {
|
||||
if (isNewSourcesAssimilated.getAndSet(true)) {
|
||||
return false
|
||||
}
|
||||
suspend fun assimilateNewSources(): Set<MangaSource> {
|
||||
val new = getNewSources()
|
||||
if (new.isEmpty()) {
|
||||
return false
|
||||
return emptySet()
|
||||
}
|
||||
var maxSortKey = dao.getMaxSortKey()
|
||||
val entities = new.map { x ->
|
||||
@@ -212,15 +167,17 @@ class MangaSourcesRepository @Inject constructor(
|
||||
source = x.name,
|
||||
isEnabled = false,
|
||||
sortKey = ++maxSortKey,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
)
|
||||
}
|
||||
dao.insertIfAbsent(entities)
|
||||
return true
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
new.removeAll { x -> x.isNsfw() }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
suspend fun isSetupRequired(): Boolean {
|
||||
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
|
||||
return dao.findAll().isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
||||
@@ -239,7 +196,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
val entities = dao.findAll()
|
||||
val result = EnumSet.copyOf(remoteSources)
|
||||
for (e in entities) {
|
||||
result.remove(e.source.toMangaSourceOrNull() ?: continue)
|
||||
result.remove(MangaSource(e.source))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -247,10 +204,10 @@ class MangaSourcesRepository @Inject constructor(
|
||||
private fun List<MangaSourceEntity>.toSources(
|
||||
skipNsfwSources: Boolean,
|
||||
sortOrder: SourcesSortOrder?,
|
||||
): MutableList<MangaSource> {
|
||||
): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
for (entity in this) {
|
||||
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||
val source = MangaSource(entity.source)
|
||||
if (skipNsfwSources && source.isNsfw()) {
|
||||
continue
|
||||
}
|
||||
@@ -268,9 +225,11 @@ class MangaSourcesRepository @Inject constructor(
|
||||
isNsfwContentDisabled
|
||||
}
|
||||
|
||||
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
|
||||
isNewSourcesTipEnabled
|
||||
}
|
||||
|
||||
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
|
||||
sourcesSortOrder
|
||||
}
|
||||
|
||||
private fun String.toMangaSourceOrNull(): MangaSource? = MangaSource.entries.find { it.name == this }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -39,11 +40,13 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||
import javax.inject.Inject
|
||||
@@ -53,7 +56,7 @@ class ExploreFragment :
|
||||
BaseFragment<FragmentExploreBinding>(),
|
||||
RecyclerViewOwner,
|
||||
ExploreListEventListener,
|
||||
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
|
||||
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -71,7 +74,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
}
|
||||
sourceSelectionController = ListSelectionController(
|
||||
@@ -121,6 +124,18 @@ class ExploreFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrimaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryButtonClick(tipView: TipView) {
|
||||
when ((tipView.tag as? TipModel)?.key) {
|
||||
ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val intent = when (v.id) {
|
||||
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
|
||||
|
||||
@@ -102,6 +102,12 @@ class ExploreViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun discardNewSources() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.assimilateNewSources()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPinShortcut(source: MangaSource) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
shortcutManager.requestPinShortcut(source)
|
||||
@@ -118,7 +124,7 @@ class ExploreViewModel @Inject constructor(
|
||||
getSuggestionFlow(),
|
||||
isGrid,
|
||||
isRandomLoading,
|
||||
sourcesRepository.observeHasNewSourcesForBadge(),
|
||||
sourcesRepository.observeNewSources(),
|
||||
) { content, suggestions, grid, randomLoading, newSources ->
|
||||
buildList(content, suggestions, grid, randomLoading, newSources)
|
||||
}.withErrorHandling()
|
||||
@@ -128,7 +134,7 @@ class ExploreViewModel @Inject constructor(
|
||||
recommendation: List<Manga>,
|
||||
isGrid: Boolean,
|
||||
randomLoading: Boolean,
|
||||
hasNewSources: Boolean,
|
||||
newSources: Set<MangaSource>,
|
||||
): List<ListModel> {
|
||||
val result = ArrayList<ListModel>(sources.size + 3)
|
||||
result += ExploreButtons(randomLoading)
|
||||
@@ -140,7 +146,7 @@ class ExploreViewModel @Inject constructor(
|
||||
result += ListHeader(
|
||||
textRes = R.string.remote_sources,
|
||||
buttonTextRes = R.string.catalog,
|
||||
badge = if (hasNewSources) "" else null,
|
||||
badge = if (newSources.isNotEmpty()) "" else null,
|
||||
)
|
||||
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
|
||||
} else {
|
||||
@@ -185,5 +191,6 @@ class ExploreViewModel @Inject constructor(
|
||||
|
||||
private const val TIP_SUGGESTIONS = "suggestions"
|
||||
private const val SUGGESTIONS_COUNT = 8
|
||||
const val TIP_NEW_SOURCES = "new_sources"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.tipAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@@ -16,6 +18,7 @@ class ExploreAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ExploreListEventListener,
|
||||
tipClickListener: TipView.OnButtonClickListener,
|
||||
clickListener: OnListItemClickListener<MangaSourceItem>,
|
||||
mangaClickListener: OnListItemClickListener<Manga>,
|
||||
) : BaseListAdapter<ListModel>() {
|
||||
@@ -31,5 +34,6 @@ class ExploreAdapter(
|
||||
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
|
||||
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.TIP, tipAD(tipClickListener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,20 +27,15 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@@ -57,21 +52,16 @@ abstract class FavouritesDao {
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
arrayOf<Any>(categoryId),
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
||||
|
||||
@@ -38,8 +38,8 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order, limit)
|
||||
fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, limit)
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
return observeOrder(categoryId)
|
||||
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
|
||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||
}
|
||||
|
||||
fun observeMangaCount(): Flow<Int> {
|
||||
@@ -63,6 +63,12 @@ class FavouritesRepository @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun getCategories(): List<FavouriteCategory> {
|
||||
return db.getFavouriteCategoriesDao().findAll().map {
|
||||
it.toFavouriteCategory()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||
return db.getFavouriteCategoriesDao().observeAll().mapItems {
|
||||
it.toFavouriteCategory()
|
||||
|
||||
@@ -33,7 +33,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
||||
binding.recyclerView.isVP2BugWorkaroundEnabled = true
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onFilterClick(view: View?) {
|
||||
val menu = PopupMenu(view?.context ?: return, view)
|
||||
|
||||
@@ -32,11 +32,8 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
@@ -49,8 +46,6 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
private val refreshTrigger = MutableStateFlow(Any())
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
|
||||
@@ -66,7 +61,13 @@ class FavouritesListViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
override val content = combine(
|
||||
observeFavorites(),
|
||||
if (categoryId == NO_ID) {
|
||||
sortOrder.filterNotNull().flatMapLatest {
|
||||
repository.observeAll(it)
|
||||
}
|
||||
} else {
|
||||
repository.observeAll(categoryId)
|
||||
},
|
||||
listMode,
|
||||
refreshTrigger,
|
||||
) { list, mode, _ ->
|
||||
@@ -84,10 +85,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
list.toUi(mode, listExtraProvider)
|
||||
}
|
||||
else -> list.toUi(mode, listExtraProvider)
|
||||
}
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
@@ -128,19 +126,4 @@ class FavouritesListViewModel @Inject constructor(
|
||||
repository.setCategoryOrder(categoryId, order)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeFavorites() = if (categoryId == NO_ID) {
|
||||
combine(sortOrder.filterNotNull(), limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAll(it.first, it.second) }
|
||||
} else {
|
||||
limit.flatMapLatest {
|
||||
repository.observeAll(categoryId, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
oldValue.copy(
|
||||
tagsExclude = newTags,
|
||||
tags = oldValue.tags - newTags,
|
||||
tags = oldValue.tags - newTags
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,7 @@ class FilterCoordinator @Inject constructor(
|
||||
currentState.update { oldValue ->
|
||||
oldValue.copy(
|
||||
tags = tags,
|
||||
tagsExclude = oldValue.tagsExclude - tags,
|
||||
tagsExclude = oldValue.tagsExclude - tags
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -391,7 +391,9 @@ class FilterCoordinator @Inject constructor(
|
||||
val result = LinkedList<ChipsView.ChipModel>()
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = selectedTags.remove(tag),
|
||||
data = tag,
|
||||
@@ -404,7 +406,9 @@ class FilterCoordinator @Inject constructor(
|
||||
}
|
||||
for (tag in selectedTags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
|
||||
@@ -61,7 +61,10 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
}
|
||||
|
||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -122,7 +122,10 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.spinnerLocale.context,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
android.R.id.text1,
|
||||
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||
value.availableItems.map {
|
||||
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
||||
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
||||
},
|
||||
)
|
||||
val selectedIndex = value.availableItems.indexOf(selected)
|
||||
if (selectedIndex >= 0) {
|
||||
@@ -141,7 +144,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||
value.selectedItems.mapTo(chips) { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = true,
|
||||
data = tag,
|
||||
@@ -150,7 +155,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -161,8 +168,12 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenres.setChips(chips)
|
||||
@@ -189,7 +200,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||
if (tag !in value.selectedItems) {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = false,
|
||||
data = tag,
|
||||
@@ -200,8 +213,12 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
chips.add(
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(R.string.more),
|
||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = null,
|
||||
),
|
||||
)
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
@@ -216,7 +233,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { state ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(state.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = state in value.selectedItems,
|
||||
data = state,
|
||||
@@ -234,7 +253,9 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
}
|
||||
val chips = value.availableItems.map { contentRating ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(contentRating.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = contentRating in value.selectedItems,
|
||||
data = contentRating,
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
|
||||
@@ -27,7 +28,8 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
|
||||
// TODO pagination
|
||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||
val orderBy = when (order) {
|
||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||
@@ -41,18 +43,13 @@ abstract class HistoryDao {
|
||||
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||
}
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
|
||||
|
||||
@@ -74,8 +74,8 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order, limit).mapItems {
|
||||
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order).mapItems {
|
||||
MangaWithHistory(
|
||||
it.manga.toManga(it.tags.toMangaTags()),
|
||||
it.history.toMangaHistory(),
|
||||
|
||||
@@ -32,7 +32,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
override fun onScrolledToEnd() = Unit
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
startActivity(NetworkManageIntent())
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.history.ui
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -44,11 +43,8 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryListViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
@@ -66,11 +62,8 @@ class HistoryListViewModel @Inject constructor(
|
||||
valueProducer = { historySortOrder },
|
||||
)
|
||||
|
||||
override val listMode = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||
valueProducer = { historyListMode },
|
||||
)
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode)
|
||||
|
||||
private val isGroupingEnabled = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_HISTORY_GROUPING,
|
||||
@@ -79,9 +72,6 @@ class HistoryListViewModel @Inject constructor(
|
||||
g && s.isGroupingSupported()
|
||||
}
|
||||
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_STATS_ENABLED,
|
||||
@@ -89,7 +79,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
observeHistory(),
|
||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
||||
isGroupingEnabled,
|
||||
listMode,
|
||||
networkState,
|
||||
@@ -105,10 +95,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
mapList(list, grouped, mode, online, incognito)
|
||||
}
|
||||
else -> mapList(list, grouped, mode, online, incognito)
|
||||
}
|
||||
}.onStart {
|
||||
loadingCounter.increment()
|
||||
@@ -151,15 +138,6 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
|
||||
|
||||
private suspend fun mapList(
|
||||
list: List<MangaWithHistory>,
|
||||
grouped: Boolean,
|
||||
|
||||
@@ -7,12 +7,11 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ErrorResult
|
||||
@@ -21,26 +20,17 @@ import coil.request.SuccessResult
|
||||
import coil.target.ViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
|
||||
@@ -49,45 +39,27 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var errorBinding: ItemErrorStateBinding? = null
|
||||
private val viewModel: ImageViewModel by viewModels()
|
||||
private lateinit var menuMediator: PopupMenuMediator
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||
viewBinding.buttonBack.setOnClickListener(this)
|
||||
viewBinding.buttonMenu.setOnClickListener(this)
|
||||
val imageUrl = requireNotNull(intent.data)
|
||||
|
||||
val menuProvider = ImageMenuProvider(
|
||||
activity = this,
|
||||
snackbarHost = viewBinding.root,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
menuMediator = PopupMenuMediator(menuProvider)
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
|
||||
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
|
||||
loadImage(imageUrl)
|
||||
loadImage(intent.data)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.buttonBack.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
}
|
||||
viewBinding.buttonMenu.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
with(viewBinding.buttonBack) {
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + marginBottom
|
||||
leftMargin = insets.left + marginBottom
|
||||
rightMargin = insets.right + marginBottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_back -> dispatchNavigateUp()
|
||||
R.id.button_menu -> menuMediator.onLongClick(v)
|
||||
else -> loadImage(intent.data)
|
||||
}
|
||||
}
|
||||
@@ -120,34 +92,11 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.lifecycle(this)
|
||||
.listener(this)
|
||||
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.target(SsivTarget(viewBinding.ssiv))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private fun onImageSaved(uri: Uri) {
|
||||
Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.share) {
|
||||
ShareHelper(this).shareImage(uri)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonMenu
|
||||
button.isClickable = !isLoading
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||
}
|
||||
}
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : ViewTarget<SubsamplingScaleImageView> {
|
||||
@@ -175,7 +124,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
|
||||
companion object {
|
||||
|
||||
const val EXTRA_SOURCE = "source"
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
||||
return Intent(context, ImageActivity::class.java)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
|
||||
class ImageMenuProvider(
|
||||
private val activity: ComponentActivity,
|
||||
private val snackbarHost: View,
|
||||
private val viewModel: ImageViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
private val permissionLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
private val saveLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("image/png"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
viewModel.saveImage(uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_image, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_save -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
saveImage()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun saveImage() {
|
||||
val name = activity.intent.data?.let {
|
||||
if (it.isZipUri()) {
|
||||
it.fragment
|
||||
} else {
|
||||
it.lastPathSegment
|
||||
}?.substringBeforeLast('.')?.plus(".png")
|
||||
}
|
||||
if (name == null || !saveLauncher.tryLaunch(name)) {
|
||||
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ImageViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val coil: ImageLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onImageSaved = MutableEventFlow<Uri>()
|
||||
|
||||
fun saveImage(destination: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.memoryCachePolicy(CachePolicy.READ_ONLY)
|
||||
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
|
||||
.build()
|
||||
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(destination)?.use { output ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
|
||||
} ?: error("Cannot open output stream")
|
||||
}
|
||||
onImageSaved.call(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ suspend fun Manga.toListDetailedModel(
|
||||
ChipsView.ChipModel(
|
||||
tint = extraProvider?.getTagTint(it) ?: 0,
|
||||
title = it.title,
|
||||
icon = 0,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
data = it,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -85,7 +85,10 @@ class PreviewViewModel @Inject constructor(
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = extraProvider.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
@@ -227,11 +227,9 @@ class LocalMangaRepository @Inject constructor(
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
dir.children().filterNot { it.isHidden }
|
||||
}
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
||||
dir.children()
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package org.koitharu.kotatsu.main.ui.protect
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScreenshotPolicyHelper @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : DefaultActivityLifecycleCallbacks {
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
(activity as? ContentContainer)?.setupScreenshotPolicy(activity)
|
||||
}
|
||||
|
||||
private fun ContentContainer.setupScreenshotPolicy(activity: Activity) =
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }
|
||||
.flatMapLatest { policy ->
|
||||
when (policy) {
|
||||
ScreenshotsPolicy.ALLOW -> flowOf(false)
|
||||
ScreenshotsPolicy.BLOCK_NSFW -> withContext(Dispatchers.Main) {
|
||||
isNsfwContent()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
ScreenshotsPolicy.BLOCK_ALL -> flowOf(true)
|
||||
ScreenshotsPolicy.BLOCK_INCOGNITO -> settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) {
|
||||
isIncognitoModeEnabled
|
||||
}
|
||||
}
|
||||
}.collect { isSecure ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSecure) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentContainer : LifecycleOwner {
|
||||
|
||||
@MainThread
|
||||
fun isNsfwContent(): Flow<Boolean>
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.databinding.SheetWelcomeBinding
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||
import java.util.Locale
|
||||
|
||||
@@ -58,7 +58,7 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setTypeChecked(data, chip.isChecked)
|
||||
is Locale -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,12 +86,14 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale>) {
|
||||
private fun onLocalesChanged(value: FilterProperty<Locale?>) {
|
||||
val chips = viewBinding?.chipsLocales ?: return
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
title = it.getDisplayName(chips.context),
|
||||
tint = 0,
|
||||
title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
@@ -105,7 +107,9 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
|
||||
chips.setChips(
|
||||
value.availableItems.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = getString(it.titleResId),
|
||||
icon = 0,
|
||||
isCheckable = true,
|
||||
isChecked = it in value.selectedItems,
|
||||
data = it,
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
@@ -28,14 +27,14 @@ class WelcomeViewModel @Inject constructor(
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val allSources = repository.allMangaSources
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale.toLocale() } }
|
||||
private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } }
|
||||
|
||||
private var updateJob: Job
|
||||
|
||||
val locales = MutableStateFlow(
|
||||
FilterProperty<Locale>(
|
||||
availableItems = listOf(Locale.ROOT),
|
||||
selectedItems = setOf(Locale.ROOT),
|
||||
FilterProperty<Locale?>(
|
||||
availableItems = listOf(null),
|
||||
selectedItems = setOf(null),
|
||||
isLoading = true,
|
||||
error = null,
|
||||
),
|
||||
@@ -52,23 +51,22 @@ class WelcomeViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
updateJob = launchJob(Dispatchers.Default) {
|
||||
val languages = localesGroups.keys.associateBy { x -> x.language }
|
||||
val selectedLocales = HashSet<Locale>(2)
|
||||
ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
val languages = localesGroups.keys.associateBy { x -> x?.language }
|
||||
val selectedLocales = HashSet<Locale?>(2)
|
||||
selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList()
|
||||
.firstNotNullOfOrNull { lc -> languages[lc.language] }
|
||||
?.let { selectedLocales += it }
|
||||
selectedLocales += Locale.ROOT
|
||||
selectedLocales += null
|
||||
locales.value = locales.value.copy(
|
||||
availableItems = localesGroups.keys.sortedWithSafe(LocaleComparator()),
|
||||
availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())),
|
||||
selectedItems = selectedLocales,
|
||||
isLoading = false,
|
||||
)
|
||||
repository.clearNewSourcesBadge()
|
||||
repository.assimilateNewSources()
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
fun setLocaleChecked(locale: Locale, isChecked: Boolean) {
|
||||
fun setLocaleChecked(locale: Locale?, isChecked: Boolean) {
|
||||
val snapshot = locales.value
|
||||
locales.value = snapshot.copy(
|
||||
selectedItems = if (isChecked) {
|
||||
@@ -101,7 +99,7 @@ class WelcomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun commit() {
|
||||
val languages = locales.value.selectedItems.mapToSet { it.language }
|
||||
val languages = locales.value.selectedItems.mapToSet { it?.language }
|
||||
val types = types.value.selectedItems
|
||||
val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||
x.contentType in types && x.locale in languages
|
||||
|
||||
@@ -7,8 +7,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -143,6 +142,7 @@ class ReaderActivity :
|
||||
viewModel.content.observe(this) {
|
||||
onLoadingStateChanged(viewModel.isLoading.value)
|
||||
}
|
||||
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
@@ -179,8 +179,6 @@ class ReaderActivity :
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
@@ -299,6 +297,14 @@ class ReaderActivity :
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setWindowSecure(isSecure: Boolean) {
|
||||
if (isSecure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -36,17 +36,6 @@ class ReaderControlDelegate(
|
||||
}
|
||||
|
||||
fun onKeyDown(keyCode: Int): Boolean = when (keyCode) {
|
||||
|
||||
KeyEvent.KEYCODE_R -> {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_L -> {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
|
||||
@@ -11,9 +11,7 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
@@ -48,10 +46,8 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
private var insetLeft: Int = 0
|
||||
private var insetRight: Int = 0
|
||||
private var insetTop: Int = 0
|
||||
private val insetLeftFallback: Int
|
||||
private val insetRightFallback: Int
|
||||
private val insetTopFallback: Int
|
||||
private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
private var cutoutInsetLeft = 0
|
||||
private var cutoutInsetRight = 0
|
||||
private val colorText = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
|
||||
200,
|
||||
@@ -84,12 +80,14 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.strokeWidth = getDimension(R.styleable.ReaderInfoBarView_android_strokeWidth, 2f)
|
||||
paint.textSize = getDimension(R.styleable.ReaderInfoBarView_android_textSize, 16f)
|
||||
}
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start")
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end")
|
||||
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback)
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner
|
||||
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
|
||||
insetLeftFallback = if (isRtl) insetEnd else insetStart
|
||||
insetRightFallback = if (isRtl) insetStart else insetEnd
|
||||
insetTopFallback = minOf(insetLeftFallback, insetRightFallback)
|
||||
insetLeft = if (isRtl) insetEnd else insetStart
|
||||
insetRight = if (isRtl) insetStart else insetEnd
|
||||
insetTop = minOf(insetLeft, insetRight)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
@@ -112,12 +110,12 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawTextOutline(
|
||||
text,
|
||||
(paddingLeft + insetLeft).toFloat(),
|
||||
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
if (isTimeVisible) {
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
var endX = (width - paddingRight - insetRight).toFloat()
|
||||
var endX = (width - paddingRight - insetRight - cutoutInsetRight).toFloat()
|
||||
canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty)
|
||||
if (batteryText.isNotEmpty()) {
|
||||
paint.getTextBounds(timeText, 0, timeText.length, textBounds)
|
||||
@@ -223,29 +221,15 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
|
||||
insetLeft = insetLeftFallback
|
||||
insetRight = insetRightFallback
|
||||
insetTop = insetTopFallback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && insetsCompat != null) {
|
||||
val nativeInsets = insetsCompat.toWindowInsets()
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.let { corner ->
|
||||
insetLeft += corner.radius
|
||||
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
|
||||
cutoutInsetLeft = 0
|
||||
cutoutInsetRight = 0
|
||||
for (rect in cutouts) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
cutoutInsetLeft += rect.width()
|
||||
}
|
||||
nativeInsets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.let { corner ->
|
||||
insetRight += corner.radius
|
||||
}
|
||||
} else {
|
||||
insetLeft += insetCornerFallback
|
||||
insetRight += insetCornerFallback
|
||||
}
|
||||
insetsCompat?.displayCutout?.let { cutout ->
|
||||
for (rect in cutout.boundingRects) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
insetLeft += rect.width()
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
insetRight += rect.width()
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
cutoutInsetRight += rect.width()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -39,6 +38,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
@@ -166,9 +166,13 @@ class ReaderViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
|
||||
)
|
||||
|
||||
val isMangaNsfw = mangaFlow.map {
|
||||
it?.isNsfw == true
|
||||
}
|
||||
val isScreenshotsBlockEnabled = combine(
|
||||
mangaFlow,
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
|
||||
) { manga, policy ->
|
||||
policy == ScreenshotsPolicy.BLOCK_ALL ||
|
||||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
|
||||
val isBookmarkAdded = currentState.flatMapLatest { state ->
|
||||
val manga = mangaData.value?.toManga()
|
||||
|
||||
@@ -15,14 +15,11 @@ import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -61,8 +58,6 @@ class MangaListActivity :
|
||||
"Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}"
|
||||
}.filter
|
||||
|
||||
private var source: MangaSource? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
@@ -71,19 +66,16 @@ class MangaListActivity :
|
||||
if (viewBinding.containerFilterHeader != null) {
|
||||
viewBinding.appbar.addOnOffsetChangedListener(this)
|
||||
}
|
||||
source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
val src = source
|
||||
if (src == null) {
|
||||
val source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
|
||||
if (source == null) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (src == MangaSource.LOCAL) getString(R.string.local_storage) else src.title
|
||||
initList(src, tags)
|
||||
return
|
||||
}
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title
|
||||
initList(source, tags)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(source?.isNsfw() == true)
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
|
||||
@@ -172,8 +172,12 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
|
||||
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
tint = 0,
|
||||
title = tag.title,
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,6 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTitle(title: CharSequence?) {
|
||||
if (!resources.getBoolean(R.bool.is_tablet)) {
|
||||
super.setTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) {
|
||||
findPreference<Preference>(key)?.summary = items.joinToString { getString(it) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
@@ -41,12 +44,9 @@ class SettingsActivity :
|
||||
private val isMasterDetails
|
||||
get() = viewBinding.containerMaster != null
|
||||
|
||||
private var screenPadding = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
|
||||
screenPadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
val currentFragment = fm.findFragmentById(R.id.container)
|
||||
@@ -59,7 +59,38 @@ class SettingsActivity :
|
||||
replace(R.id.container_master, RootSettingsFragment())
|
||||
}
|
||||
}
|
||||
addMenuProvider(SettingsMenuProvider(this))
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_tracker -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity")
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "org.koitharu.workinspector.WorkInspectorActivity")
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
@@ -78,8 +109,8 @@ class SettingsActivity :
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.textViewHeader?.updateLayoutParams<MarginLayoutParams> {
|
||||
topMargin = screenPadding + insets.top
|
||||
viewBinding.cardDetails?.updateLayoutParams<MarginLayoutParams> {
|
||||
bottomMargin = marginStart + insets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +125,7 @@ class SettingsActivity :
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN)
|
||||
if (!isMasterDetails || (hasFragment && !isFromRoot)) {
|
||||
addToBackStack(null)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ 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
|
||||
@@ -41,12 +40,6 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
|
||||
if (!isEnabled) isChecked = true
|
||||
}
|
||||
if (!settings.isTrackerEnabled) {
|
||||
findPreference<Preference>(AppSettings.KEY_TRACKER_DEBUG)?.run {
|
||||
isEnabled = false
|
||||
setSummary(R.string.check_for_new_chapters_disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -74,12 +67,6 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACKER_DEBUG -> {
|
||||
startActivity(Intent(preference.context, TrackerDebugActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NewSourcesDialogFragment :
|
||||
AlertDialogFragment<DialogOnboardBinding>(),
|
||||
SourceConfigListener,
|
||||
DialogInterface.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<NewSourcesViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): DialogOnboardBinding {
|
||||
return DialogOnboardBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setPositiveButton(R.string.done, this)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.remote_sources)
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.onItemEnabledChanged(item, isEnabled)
|
||||
}
|
||||
|
||||
override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "NewSources"
|
||||
|
||||
fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewSourcesViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val newSources = SuspendLazy {
|
||||
repository.assimilateNewSources()
|
||||
}
|
||||
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
|
||||
.map { sources ->
|
||||
val new = newSources.get()
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
sources.mapNotNull { (source, enabled) ->
|
||||
if (source in new) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = source,
|
||||
isEnabled = enabled,
|
||||
isDraggable = false,
|
||||
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.setSourcesEnabled(setOf(item.source), isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourcesSelectAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceConfigItem>() {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ sealed interface SourceCatalogItem : ListModel {
|
||||
|
||||
data class Source(
|
||||
val source: MangaSource,
|
||||
val showSummary: Boolean,
|
||||
) : SourceCatalogItem {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -16,7 +15,6 @@ import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
@@ -24,32 +22,28 @@ import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun sourceCatalogItemSourceAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, ListModel, ItemSourceCatalogBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Source, SourceCatalogItem, ItemSourceCatalogBinding>(
|
||||
{ layoutInflater, parent ->
|
||||
ItemSourceCatalogBinding.inflate(layoutInflater, parent, false)
|
||||
},
|
||||
) {
|
||||
|
||||
binding.imageViewAdd.setOnClickListener { v ->
|
||||
listener.onItemLongClick(item, v)
|
||||
}
|
||||
binding.root.setOnClickListener { v ->
|
||||
listener.onItemClick(item, v)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.getTitle(context)
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.drawableStart = if (item.source.isBroken) {
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_off_small)
|
||||
if (item.showSummary) {
|
||||
binding.textViewDescription.text = item.source.getSummary(context)
|
||||
binding.textViewDescription.isVisible = true
|
||||
} else {
|
||||
null
|
||||
binding.textViewDescription.isVisible = false
|
||||
}
|
||||
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
@@ -67,7 +61,7 @@ fun sourceCatalogItemSourceAD(
|
||||
fun sourceCatalogItemHintAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, ListModel, ItemEmptyHintBinding>(
|
||||
) = adapterDelegateViewBinding<SourceCatalogItem.Hint, SourceCatalogItem, ItemEmptyHintBinding>(
|
||||
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
|
||||
@@ -1,46 +1,41 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView.ChipModel
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener {
|
||||
AppBarOwner, MenuItem.OnActionExpandListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var newSourcesSnackbar: Snackbar? = null
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
@@ -50,20 +45,18 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val sourcesAdapter = SourcesCatalogAdapter(this, coil, this)
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = sourcesAdapter
|
||||
}
|
||||
viewBinding.chipsFilter.onChipClickListener = this
|
||||
viewModel.content.observe(this, sourcesAdapter)
|
||||
val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this)
|
||||
viewBinding.pager.adapter = pagerAdapter
|
||||
val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter)
|
||||
tabMediator.attach()
|
||||
viewModel.content.observe(this, pagerAdapter)
|
||||
viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged)
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
ReversibleActionObserver(viewBinding.recyclerView),
|
||||
ReversibleActionObserver(viewBinding.pager),
|
||||
)
|
||||
combine(viewModel.appliedFilter, viewModel.hasNewSources, ::Pair).observe(this) {
|
||||
updateFilers(it.first, it.second)
|
||||
viewModel.locale.observe(this) {
|
||||
supportActionBar?.subtitle = it?.toLocale().getDisplayName(this)
|
||||
}
|
||||
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this))
|
||||
}
|
||||
@@ -73,85 +66,51 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is ContentType -> viewModel.setContentType(data, chip.isChecked)
|
||||
is Boolean -> viewModel.setNewOnly(chip.isChecked)
|
||||
else -> showLocalesMenu(chip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
|
||||
startActivity(MangaListActivity.newIntent(this, item.source))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean {
|
||||
viewModel.addSource(item.source)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = false
|
||||
viewBinding.pager.isUserInputEnabled = false
|
||||
val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty()
|
||||
viewModel.performSearch(sq)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
viewBinding.tabs.isVisible = true
|
||||
viewBinding.pager.isUserInputEnabled = true
|
||||
viewModel.performSearch(null)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateFilers(
|
||||
appliedFilter: SourcesCatalogFilter,
|
||||
hasNewSources: Boolean,
|
||||
) {
|
||||
val chips = ArrayList<ChipModel>(ContentType.entries.size + 2)
|
||||
chips += ChipModel(
|
||||
title = appliedFilter.locale?.toLocale().getDisplayName(this),
|
||||
icon = R.drawable.ic_language,
|
||||
isDropdown = true,
|
||||
)
|
||||
private fun onHasNewSourcesChanged(hasNewSources: Boolean) {
|
||||
if (hasNewSources) {
|
||||
chips += ChipModel(
|
||||
title = getString(R.string._new),
|
||||
icon = R.drawable.ic_updated_selector,
|
||||
isCheckable = true,
|
||||
isChecked = appliedFilter.isNewOnly,
|
||||
data = true,
|
||||
)
|
||||
}
|
||||
for (type in ContentType.entries) {
|
||||
if (type == ContentType.HENTAI && viewModel.isNsfwDisabled) {
|
||||
continue
|
||||
if (newSourcesSnackbar?.isShownOrQueued == true) {
|
||||
return
|
||||
}
|
||||
chips += ChipModel(
|
||||
title = getString(type.titleResId),
|
||||
isCheckable = true,
|
||||
isChecked = type in appliedFilter.types,
|
||||
data = type,
|
||||
val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE)
|
||||
snackbar.setAction(R.string.explore) {
|
||||
NewSourcesDialogFragment.show(supportFragmentManager)
|
||||
}
|
||||
snackbar.addCallback(
|
||||
object : Snackbar.Callback() {
|
||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||
super.onDismissed(transientBottomBar, event)
|
||||
if (event == DISMISS_EVENT_SWIPE) {
|
||||
viewModel.skipNewSources()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
snackbar.show()
|
||||
newSourcesSnackbar = snackbar
|
||||
} else {
|
||||
newSourcesSnackbar?.dismiss()
|
||||
newSourcesSnackbar = null
|
||||
}
|
||||
viewBinding.chipsFilter.setChips(chips)
|
||||
}
|
||||
|
||||
private fun showLocalesMenu(anchor: View) {
|
||||
val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) {
|
||||
it to it?.toLocale()
|
||||
}
|
||||
locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second })
|
||||
val menu = PopupMenu(this, anchor)
|
||||
for ((i, lc) in locales.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(this))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setLocale(locales.getOrNull(it.order)?.first)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,16 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class SourcesCatalogAdapter(
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
) : BaseListAdapter<SourceCatalogItem>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER_LIST, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner))
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
|
||||
data class SourcesCatalogFilter(
|
||||
val types: Set<ContentType>,
|
||||
val locale: String?,
|
||||
val isNewOnly: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.ViewModelLifecycle
|
||||
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
|
||||
import org.koitharu.kotatsu.core.db.removeObserverAsync
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
|
||||
class SourcesCatalogListProducer @AssistedInject constructor(
|
||||
@Assisted private val locale: String?,
|
||||
@Assisted private val contentType: ContentType,
|
||||
@Assisted lifecycle: ViewModelLifecycle,
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val database: MangaDatabase,
|
||||
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
|
||||
|
||||
private val scope = lifecycle.lifecycleScope
|
||||
private var query: String? = null
|
||||
val list = MutableStateFlow(emptyList<SourceCatalogItem>())
|
||||
|
||||
private var job = scope.launch(Dispatchers.Default) {
|
||||
list.value = buildList()
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
|
||||
}
|
||||
lifecycle.addOnClearedListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
database.invalidationTracker.removeObserverAsync(this)
|
||||
}
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob.cancelAndJoin()
|
||||
list.update { buildList() }
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(value: String?) {
|
||||
this.query = value
|
||||
onInvalidated(emptySet())
|
||||
}
|
||||
|
||||
private suspend fun buildList(): List<SourceCatalogItem> {
|
||||
val sources = repository.getDisabledSources().toMutableList()
|
||||
when (val q = query) {
|
||||
null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
|
||||
"" -> return emptyList()
|
||||
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
|
||||
}
|
||||
return if (sources.isEmpty()) {
|
||||
listOf(
|
||||
if (query == null) {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.no_manga_sources,
|
||||
text = R.string.no_manga_sources_catalog_text,
|
||||
)
|
||||
} else {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.nothing_found,
|
||||
text = R.string.no_manga_sources_found,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
sources.sortBy { it.title }
|
||||
sources.map {
|
||||
SourceCatalogItem.Source(
|
||||
source = it,
|
||||
showSummary = query != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
locale: String?,
|
||||
contentType: ContentType,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
): SourcesCatalogListProducer
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import android.app.Activity
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
|
||||
class SourcesCatalogMenuProvider(
|
||||
@@ -27,7 +32,14 @@ class SourcesCatalogMenuProvider(
|
||||
searchView.queryHint = searchMenuItem.title
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_locales -> {
|
||||
showLocalesMenu()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
||||
@@ -45,4 +57,24 @@ class SourcesCatalogMenuProvider(
|
||||
viewModel.performSearch(newText?.trim().orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showLocalesMenu() {
|
||||
val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) {
|
||||
it to it?.toLocale()
|
||||
}
|
||||
locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second })
|
||||
|
||||
val anchor: View = (activity as AppBarOwner).appBar.let {
|
||||
it.findViewById<View?>(R.id.toolbar) ?: it
|
||||
}
|
||||
val menu = PopupMenu(activity, anchor)
|
||||
for ((i, lc) in locales.withIndex()) {
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(activity))
|
||||
}
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setLocale(locales.getOrNull(it.order)?.first)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
|
||||
class SourcesCatalogPagerAdapter(
|
||||
listener: OnListItemClickListener<SourceCatalogItem.Source>,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceCatalogPage>(), TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceCatalogPageAD(listener, coil, lifecycleOwner))
|
||||
}
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val item = items.getOrNull(position) ?: return
|
||||
tab.setText(item.type.titleResId)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
package org.koitharu.kotatsu.settings.sources.catalog
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.invalidationTrackerFlow
|
||||
import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.EnumMap
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -31,47 +31,41 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SourcesCatalogViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
db: MangaDatabase,
|
||||
settings: AppSettings,
|
||||
private val listProducerFactory: SourcesCatalogListProducer.Factory,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val lifecycle = RetainedLifecycleImpl()
|
||||
private var searchQuery: String? = null
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val locales: Set<String?> = repository.allMangaSources.mapTo(HashSet<String?>()) { it.locale }.also {
|
||||
it.add(null)
|
||||
}
|
||||
val locales = repository.allMangaSources.mapToSet { it.locale }
|
||||
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
|
||||
|
||||
private val searchQuery = MutableStateFlow<String?>(null)
|
||||
val appliedFilter = MutableStateFlow(
|
||||
SourcesCatalogFilter(
|
||||
types = emptySet(),
|
||||
locale = Locale.getDefault().language.takeIf { it in locales },
|
||||
isNewOnly = false,
|
||||
),
|
||||
)
|
||||
|
||||
val isNsfwDisabled = settings.isNsfwContentDisabled
|
||||
|
||||
val hasNewSources = repository.observeHasNewSources()
|
||||
val hasNewSources = repository.observeNewSources()
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
|
||||
val content: StateFlow<List<ListModel>> = combine(
|
||||
searchQuery,
|
||||
appliedFilter,
|
||||
db.invalidationTrackerFlow(TABLE_SOURCES),
|
||||
) { q, f, _ ->
|
||||
buildSourcesList(f, q)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
private val listProducers = locale.map { lc ->
|
||||
createListProducers(lc)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
|
||||
|
||||
init {
|
||||
repository.clearNewSourcesBadge()
|
||||
val content: StateFlow<List<SourceCatalogPage>> = listProducers.flatMapLatest {
|
||||
val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } }
|
||||
combine<SourceCatalogPage, List<SourceCatalogPage>>(flows, Array<SourceCatalogPage>::toList)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
lifecycle.dispatchOnCleared()
|
||||
}
|
||||
|
||||
fun performSearch(query: String?) {
|
||||
searchQuery.value = query?.trim()
|
||||
searchQuery = query
|
||||
listProducers.value.forEach { (_, v) -> v.setQuery(query) }
|
||||
}
|
||||
|
||||
fun setLocale(value: String?) {
|
||||
appliedFilter.value = appliedFilter.value.copy(locale = value)
|
||||
locale.value = value
|
||||
}
|
||||
|
||||
fun addSource(source: MangaSource) {
|
||||
@@ -81,53 +75,21 @@ class SourcesCatalogViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setContentType(value: ContentType, isAdd: Boolean) {
|
||||
val filter = appliedFilter.value
|
||||
val types = EnumSet.noneOf(ContentType::class.java)
|
||||
types.addAll(filter.types)
|
||||
if (isAdd) {
|
||||
types.add(value)
|
||||
} else {
|
||||
types.remove(value)
|
||||
fun skipNewSources() {
|
||||
launchJob {
|
||||
repository.assimilateNewSources()
|
||||
}
|
||||
appliedFilter.value = filter.copy(types = types)
|
||||
}
|
||||
|
||||
fun setNewOnly(value: Boolean) {
|
||||
appliedFilter.value = appliedFilter.value.copy(isNewOnly = value)
|
||||
}
|
||||
|
||||
private suspend fun buildSourcesList(filter: SourcesCatalogFilter, query: String?): List<SourceCatalogItem> {
|
||||
val sources = repository.getAvailableSources(
|
||||
isDisabledOnly = true,
|
||||
isNewOnly = filter.isNewOnly,
|
||||
excludeBroken = false,
|
||||
types = filter.types,
|
||||
query = query,
|
||||
locale = filter.locale,
|
||||
sortOrder = SourcesSortOrder.ALPHABETIC,
|
||||
)
|
||||
return if (sources.isEmpty()) {
|
||||
listOf(
|
||||
if (query == null) {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.no_manga_sources,
|
||||
text = R.string.no_manga_sources_catalog_text,
|
||||
)
|
||||
} else {
|
||||
SourceCatalogItem.Hint(
|
||||
icon = R.drawable.ic_empty_feed,
|
||||
title = R.string.nothing_found,
|
||||
text = R.string.no_manga_sources_found,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
sources.sortedBy {
|
||||
it.isBroken
|
||||
}.map {
|
||||
SourceCatalogItem.Source(source = it)
|
||||
@MainThread
|
||||
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
|
||||
val types = EnumSet.allOf(ContentType::class.java)
|
||||
if (settings.isNsfwContentDisabled) {
|
||||
types.remove(ContentType.HENTAI)
|
||||
}
|
||||
return types.associateWithTo(EnumMap(ContentType::class.java)) { type ->
|
||||
listProducerFactory.create(lc, type, lifecycle).also {
|
||||
it.setQuery(searchQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -40,13 +41,7 @@ class MangaDirectorySelectDialog : AlertDialogFragment<DialogDirectorySelectBind
|
||||
) {
|
||||
if (it) {
|
||||
viewModel.refresh()
|
||||
if (!pickFileTreeLauncher.tryLaunch(null)) {
|
||||
Toast.makeText(
|
||||
context ?: return@registerForActivityResult,
|
||||
R.string.operation_not_supported,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
pickFileTreeLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user