Compare commits
56 Commits
revert-444
...
v6.0-a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
68bdd22634
|
|||
|
2e7867f60c
|
|||
|
e4c2972cae
|
|||
|
af654b8c40
|
|||
|
9eace89d4f
|
|||
| 7dbec8eb8f | |||
| 111f816f18 | |||
|
d4589716aa
|
|||
|
1c4bd6da28
|
|||
| c83538f66d | |||
|
|
885cae7635 | ||
|
|
c66d36abf9 | ||
|
|
4bd9c6df81 | ||
|
|
b835cb98b7 | ||
|
|
7cb1f90155 | ||
|
96bac81b84
|
|||
|
b59f933031
|
|||
|
caebca36de
|
|||
|
03cb458d92
|
|||
|
0788f5f05e
|
|||
|
0271ed2ba9
|
|||
|
788c7b862a
|
|||
|
f4f84099cc
|
|||
|
3bea94bf1f
|
|||
|
746eed698f
|
|||
|
fa0289eb27
|
|||
|
c874d73c04
|
|||
|
edb91c46d4
|
|||
|
4b9f4f9af2
|
|||
|
|
a07117087a | ||
|
|
ce0ffca197 | ||
|
|
eece4d8f00 | ||
|
|
419e2e578b | ||
|
|
0a7387c22e | ||
|
|
2a23c3b3b3 | ||
|
|
0566aa4e6a | ||
|
|
3f27acf1aa | ||
|
|
d48c3fbe1b | ||
|
|
6720474667 | ||
|
|
ad42ca5085 | ||
|
|
d5e40d79ec | ||
|
|
7853b9a73e | ||
|
|
cb71a24d81 | ||
|
a5d99db105
|
|||
|
6cc13784e4
|
|||
|
cf9aab9afe
|
|||
|
83a919f9f6
|
|||
|
70bf80ad8f
|
|||
|
05a724eae0
|
|||
|
496f3637c4
|
|||
|
c5c907c8dc
|
|||
|
af4845a770
|
|||
|
511f9af991
|
|||
|
baf6f6d2c9
|
|||
|
8b6a0a8c87
|
|||
|
e7ee261680
|
11
CONTRIBUTING.md
Normal file
11
CONTRIBUTING.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## Kotatsu contribution guidelines
|
||||
|
||||
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
|
||||
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
|
||||
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
||||
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
||||
|
||||
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
|
||||
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||
- Please, do not modify readme and other information files (except for typos).
|
||||
- Avoid adding new dependencies unless required. APK size is important.
|
||||
@@ -39,6 +39,10 @@ Kotatsu is a free and open source manga reader for Android.
|
||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
||||
|
||||
### Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
||||
|
||||
### License
|
||||
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
@@ -2,30 +2,30 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'kotlin-parcelize'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 33
|
||||
buildToolsVersion = '33.0.2'
|
||||
// compileSdkExtension = 4
|
||||
buildToolsVersion = '34.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
//TODO: update as soon as sources becomes available
|
||||
//noinspection OldTargetApi
|
||||
targetSdkVersion 33
|
||||
versionCode 567
|
||||
versionName '5.3.10'
|
||||
versionCode 570
|
||||
versionName '6.0-a1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg 'room.schemaLocation', "$projectDir/schemas".toString()
|
||||
}
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
@@ -41,6 +41,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
@@ -81,11 +82,11 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:03b4fc9f00') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:407ef5b655') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
@@ -100,11 +101,10 @@ dependencies {
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.1'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1'
|
||||
|
||||
// TODO https://issuetracker.google.com/issues/254846063
|
||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||
@@ -117,8 +117,7 @@ dependencies {
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.5.2'
|
||||
implementation 'androidx.room:room-ktx:2.5.2'
|
||||
//noinspection KaptUsageInsteadOfKsp
|
||||
kapt 'androidx.room:room-compiler:2.5.2'
|
||||
ksp 'androidx.room:room-compiler:2.5.2'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.util.Log
|
||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||
|
||||
class LoggingAdapterDataObserver(
|
||||
private val tag: String,
|
||||
) : AdapterDataObserver() {
|
||||
|
||||
override fun onChanged() {
|
||||
Log.d(tag, "onChanged()")
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
|
||||
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
|
||||
Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
|
||||
}
|
||||
|
||||
override fun onStateRestorationPolicyChanged() {
|
||||
Log.d(tag, "onStateRestorationPolicyChanged()")
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:localeConfig="@xml/locales"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -166,9 +165,6 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
|
||||
android:label="@string/color_correction" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -23,7 +23,6 @@ import org.acra.sender.HttpSender
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
@@ -73,7 +72,6 @@ class KotatsuApp : Application(), Configuration.Provider {
|
||||
setupDatabaseObservers()
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
|
||||
@@ -112,7 +112,7 @@ class BookmarksFragment :
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
startActivity(intent)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.plus
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
||||
@@ -103,7 +102,7 @@ class BookmarksSheet :
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
startActivity(intent)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
@@ -18,7 +17,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
|
||||
@@ -15,7 +15,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
||||
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||
val sources = MangaSource.values()
|
||||
val sources = MangaSource.entries
|
||||
for (source in sources) {
|
||||
if (source == MangaSource.LOCAL) {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okio.IOException
|
||||
import java.util.Date
|
||||
|
||||
class TooManyRequestExceptions(
|
||||
val url: String,
|
||||
val retryAt: Date?,
|
||||
) : IOException()
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.fs
|
||||
|
||||
import android.os.Build
|
||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class FileSequence(private val dir: File) : Sequence<File> {
|
||||
|
||||
override fun iterator(): Iterator<File> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val stream = Files.newDirectoryStream(dir.toPath())
|
||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
||||
} else {
|
||||
dir.listFiles().orEmpty().iterator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ fun MangaSource.getLocaleTitle(): String? {
|
||||
}
|
||||
|
||||
fun MangaSource(name: String): MangaSource {
|
||||
MangaSource.values().forEach {
|
||||
MangaSource.entries.forEach {
|
||||
if (it.name == name) return it
|
||||
}
|
||||
return MangaSource.DUMMY
|
||||
|
||||
@@ -3,20 +3,21 @@ package org.koitharu.kotatsu.core.network
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.jsoup.Jsoup
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
||||
|
||||
private const val HEADER_SERVER = "Server"
|
||||
private const val SERVER_CLOUDFLARE = "cloudflare"
|
||||
|
||||
class CloudFlareInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
|
||||
val content = response.body?.source()?.peek()?.use {
|
||||
Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString())
|
||||
} ?: return response
|
||||
if (content.getElementById("challenge-error-title") != null) {
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
throw CloudFlareProtectedException(
|
||||
|
||||
@@ -15,6 +15,7 @@ object CommonHeaders {
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
const val CACHE_CONTROL = "Cache-Control"
|
||||
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||
const val RETRY_AFTER = "Retry-After"
|
||||
|
||||
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||
get() = CacheControl.Builder().noStore().build()
|
||||
|
||||
@@ -67,6 +67,7 @@ interface NetworkModule {
|
||||
cache(cache)
|
||||
addInterceptor(GZipInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
addInterceptor(RateLimitInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RateLimitInterceptor : Interceptor {
|
||||
|
||||
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 429) {
|
||||
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
throw TooManyRequestExceptions(
|
||||
url = request.url.toString(),
|
||||
retryAt = retryDate,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun String.parseRetryDate(): Date? {
|
||||
toIntOrNull()?.let {
|
||||
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong()))
|
||||
}
|
||||
return dateFormat.parse(this)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
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
|
||||
@@ -49,22 +51,32 @@ class FaviconFetcher(
|
||||
override suspend fun fetch(): FetchResult {
|
||||
getCached(options)?.let { return it }
|
||||
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
|
||||
val favicons = repo.getFavicons()
|
||||
val sizePx = maxOf(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
)
|
||||
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
|
||||
val response = loadIcon(icon.url, mangaSource)
|
||||
val responseBody = response.requireBody()
|
||||
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
|
||||
response.closeQuietly()
|
||||
} ?: responseBody.toImageSource(response)
|
||||
return SourceResult(
|
||||
source = source,
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
|
||||
dataSource = response.toDataSource(),
|
||||
)
|
||||
var favicons = repo.getFavicons()
|
||||
while (favicons.isNotEmpty()) {
|
||||
val icon = favicons.find(sizePx) ?: throwNSEE()
|
||||
val response = try {
|
||||
loadIcon(icon.url, mangaSource)
|
||||
} catch (e: CloudFlareProtectedException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
favicons -= icon
|
||||
continue
|
||||
}
|
||||
val responseBody = response.requireBody()
|
||||
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
|
||||
response.closeQuietly()
|
||||
} ?: responseBody.toImageSource(response)
|
||||
return SourceResult(
|
||||
source = source,
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
|
||||
dataSource = response.toDataSource(),
|
||||
)
|
||||
}
|
||||
throwNSEE()
|
||||
}
|
||||
|
||||
private suspend fun loadIcon(url: String, source: MangaSource): Response {
|
||||
@@ -143,6 +155,8 @@ class FaviconFetcher(
|
||||
append(height.toString())
|
||||
}
|
||||
|
||||
private fun throwNSEE(): Nothing = throw NoSuchElementException("No favicons found")
|
||||
|
||||
class Factory(
|
||||
context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
|
||||
@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -55,6 +55,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||
|
||||
var isNsfwContentDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
|
||||
|
||||
var appLocales: LocaleListCompat
|
||||
get() {
|
||||
val raw = prefs.getString(KEY_APP_LOCALE, null)
|
||||
@@ -100,8 +104,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val notificationLight: Boolean
|
||||
get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true)
|
||||
|
||||
val readerAnimation: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
|
||||
val readerAnimation: ReaderAnimation
|
||||
get() = prefs.getEnumValue(KEY_READER_ANIMATION, ReaderAnimation.DEFAULT)
|
||||
|
||||
val readerBackground: ReaderBackground
|
||||
get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT)
|
||||
@@ -134,7 +138,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
|
||||
|
||||
val trackSources: Set<String>
|
||||
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY)
|
||||
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: setOf(TRACK_FAVOURITES)
|
||||
|
||||
var appPassword: String?
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
@@ -333,8 +337,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
|
||||
private fun isBackgroundNetworkRestricted(): Boolean {
|
||||
return ConnectivityManagerCompat.getRestrictBackgroundStatus(connectivityManager) ==
|
||||
ConnectivityManagerCompat.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
@@ -380,7 +387,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
|
||||
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
|
||||
const val KEY_READER_ANIMATION = "reader_animation"
|
||||
const val KEY_READER_ANIMATION = "reader_animation2"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
@@ -440,6 +447,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
|
||||
enum class ColorScheme(
|
||||
@StyleRes val styleResId: Int,
|
||||
@@ -31,7 +32,7 @@ enum class ColorScheme(
|
||||
}
|
||||
|
||||
fun getAvailableList(): List<ColorScheme> {
|
||||
val list = enumValues<ColorScheme>().toMutableList()
|
||||
val list = ColorScheme.entries.toMutableList()
|
||||
if (!DynamicColors.isDynamicColorAvailable()) {
|
||||
list.remove(MONET)
|
||||
}
|
||||
@@ -39,7 +40,7 @@ enum class ColorScheme(
|
||||
}
|
||||
|
||||
fun safeValueOf(name: String): ColorScheme? {
|
||||
return enumValues<ColorScheme>().find { it.name == name }
|
||||
return ColorScheme.entries.find(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ enum class NetworkPolicy(
|
||||
|
||||
fun from(key: String?, default: NetworkPolicy): NetworkPolicy {
|
||||
val intKey = key?.toIntOrNull() ?: return default
|
||||
return enumValues<NetworkPolicy>().find { it.key == intKey } ?: default
|
||||
return NetworkPolicy.entries.find { it.key == intKey } ?: default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
enum class ReaderAnimation {
|
||||
|
||||
// Do not rename this
|
||||
NONE, DEFAULT, ADVANCED;
|
||||
}
|
||||
@@ -8,6 +8,6 @@ enum class ReaderMode(val id: Int) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
|
||||
fun valueOf(id: Int) = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
|
||||
@Suppress("DEPRECATION")
|
||||
onBackPressed()
|
||||
true
|
||||
} else super.onOptionsItemSelected(item)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,7 +34,8 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
|
||||
val themedContext = (view.parentView ?: view).context
|
||||
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
|
||||
listView.clipToPadding = false
|
||||
insetsDelegate.onViewCreated(view)
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
|
||||
@@ -7,10 +7,14 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,9 +36,8 @@ abstract class BaseViewModel : ViewModel() {
|
||||
val onError: EventFlow<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
val isLoading: StateFlow<Boolean>
|
||||
get() = loadingCounter.map { it > 0 }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), loadingCounter.value > 0)
|
||||
val isLoading: StateFlow<Boolean> = loadingCounter.map { it > 0 }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
@@ -55,14 +58,24 @@ abstract class BaseViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withLoading() = onStart {
|
||||
loadingCounter.increment()
|
||||
}.onCompletion {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
|
||||
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||
|
||||
/**
|
||||
* https://github.com/material-components/material-components-android/issues/2582
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onAttachedToWindow() {
|
||||
val window = window
|
||||
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
||||
super.onAttachedToWindow()
|
||||
if (window != null) {
|
||||
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
||||
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
||||
if (drawEdgeToEdge) {
|
||||
// Copied from super.onAttachedToWindow:
|
||||
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
// Fix super-class's window flag bug by respecting the initial system UI visibility:
|
||||
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.list.decor
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.core.content.res.getColorOrThrow
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val thickness: Int
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
init {
|
||||
paint.style = Paint.Style.FILL
|
||||
val ta = context.obtainStyledAttributes(
|
||||
null,
|
||||
materialR.styleable.MaterialDivider,
|
||||
materialR.attr.materialDividerStyle,
|
||||
materialR.style.Widget_Material3_MaterialDivider,
|
||||
)
|
||||
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
|
||||
thickness = ta.getDimensionPixelSize(
|
||||
materialR.styleable.MaterialDivider_dividerThickness,
|
||||
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
|
||||
)
|
||||
ta.recycle()
|
||||
}
|
||||
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect.set(0, thickness, 0, 0)
|
||||
}
|
||||
|
||||
// TODO implement for horizontal lists on demand
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||
if (parent.layoutManager == null || thickness == 0) {
|
||||
return
|
||||
}
|
||||
canvas.save()
|
||||
val left: Float
|
||||
val right: Float
|
||||
if (parent.clipToPadding) {
|
||||
left = parent.paddingLeft.toFloat()
|
||||
right = (parent.width - parent.paddingRight).toFloat()
|
||||
canvas.clipRect(
|
||||
left,
|
||||
parent.paddingTop.toFloat(),
|
||||
right,
|
||||
(parent.height - parent.paddingBottom).toFloat(),
|
||||
)
|
||||
} else {
|
||||
left = 0f
|
||||
right = parent.width.toFloat()
|
||||
}
|
||||
|
||||
var previous: RecyclerView.ViewHolder? = null
|
||||
for (child in parent.children) {
|
||||
val holder = parent.getChildViewHolder(child)
|
||||
if (previous != null && shouldDrawDivider(previous, holder)) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
val top: Float = bounds.top + child.translationY
|
||||
val bottom: Float = top + thickness
|
||||
canvas.drawRect(left, top, right, bottom, paint)
|
||||
}
|
||||
previous = holder
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
protected abstract fun shouldDrawDivider(
|
||||
above: RecyclerView.ViewHolder,
|
||||
below: RecyclerView.ViewHolder,
|
||||
): Boolean
|
||||
}
|
||||
@@ -519,7 +519,7 @@ class FastScroller @JvmOverloads constructor(
|
||||
|
||||
private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
|
||||
val ordinal = getInt(index, -1)
|
||||
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
|
||||
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
|
||||
}
|
||||
|
||||
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.impl.foreground.SystemForegroundService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Workaround for issue
|
||||
* https://issuetracker.google.com/issues/270245927
|
||||
* https://issuetracker.google.com/issues/280504155
|
||||
*/
|
||||
class WorkServiceStopHelper(
|
||||
private val workManagerProvider: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
fun setup() {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
workManagerProvider.get()
|
||||
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.asFlow()
|
||||
.map { it.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
if (it) {
|
||||
delay(1_000)
|
||||
stopWorkerService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun stopWorkerService() {
|
||||
SystemForegroundService.getInstance()?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
import android.app.ActivityOptions
|
||||
import android.app.LocaleConfig
|
||||
import android.content.Context
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.content.Context.POWER_SERVICE
|
||||
@@ -11,8 +14,8 @@ import android.content.ContextWrapper
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.content.res.Resources
|
||||
import android.database.SQLException
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
@@ -27,6 +30,8 @@ import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
@@ -172,10 +177,17 @@ fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimation
|
||||
null
|
||||
}
|
||||
|
||||
fun Resources.getLocalesConfig(): LocaleListCompat {
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
LocaleConfig(this).supportedLocales?.let {
|
||||
return LocaleListCompat.wrap(it)
|
||||
}
|
||||
}
|
||||
val tagsList = StringJoiner(",")
|
||||
try {
|
||||
val xpp: XmlPullParser = getXml(R.xml.locales)
|
||||
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
|
||||
val xpp: XmlPullParser = resources.getXml(resId)
|
||||
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.eventType == XmlPullParser.START_TAG) {
|
||||
if (xpp.name == "locale") {
|
||||
@@ -212,3 +224,9 @@ inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
@@ -5,15 +5,6 @@ import androidx.collection.ArraySet
|
||||
import java.util.Collections
|
||||
import java.util.EnumSet
|
||||
|
||||
@Deprecated("TODO: remove")
|
||||
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
|
||||
if (sourceIndex <= targetIndex) {
|
||||
Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
|
||||
} else {
|
||||
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
|
||||
val set = ArraySet<T>(size)
|
||||
repeat(size) { index -> set.add(init(index)) }
|
||||
@@ -41,10 +32,6 @@ fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
|
||||
return null
|
||||
}
|
||||
|
||||
inline fun <T> Collection<T>.filterToSet(predicate: (T) -> Boolean): Set<T> {
|
||||
return filterTo(ArraySet(size), predicate)
|
||||
}
|
||||
|
||||
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
|
||||
return toMutableList().apply { sortWith(comparator) }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.util.zip.ZipEntry
|
||||
@@ -73,11 +74,10 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
|
||||
|
||||
@WorkerThread
|
||||
private fun computeSizeInternal(file: File): Long {
|
||||
if (file.isDirectory) {
|
||||
val files = file.listFiles() ?: return 0L
|
||||
return files.sumOf { computeSizeInternal(it) }
|
||||
return if (file.isDirectory) {
|
||||
file.children().sumOf { computeSizeInternal(it) }
|
||||
} else {
|
||||
return file.length()
|
||||
file.length()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,8 @@ fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequen
|
||||
}
|
||||
|
||||
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
|
||||
val ss = root.list() ?: return
|
||||
for (s in ss) {
|
||||
val f = File(root, s)
|
||||
val ss = root.children()
|
||||
for (f in ss) {
|
||||
if (f.isDirectory) {
|
||||
listFilesRecursiveImpl(f, filter)
|
||||
} else if (filter == null || filter.accept(f)) {
|
||||
@@ -96,3 +95,7 @@ private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filte
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun File.children() = FileSequence(this)
|
||||
|
||||
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
|
||||
|
||||
@@ -9,8 +9,6 @@ import androidx.annotation.ColorInt
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.content.res.TypedArrayUtils
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.graphics.ColorUtils
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
@@ -32,6 +33,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
|
||||
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.core.util.iterator
|
||||
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
|
||||
class CloseableIterator<T>(
|
||||
private val upstream: Iterator<T>,
|
||||
private val closeable: Closeable,
|
||||
) : Iterator<T>, Closeable {
|
||||
|
||||
private var isClosed = false
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
val result = upstream.hasNext()
|
||||
if (!result) {
|
||||
close()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun next(): T {
|
||||
try {
|
||||
return upstream.next()
|
||||
} catch (e: NoSuchElementException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
closeable.closeQuietly()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.util.iterator
|
||||
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class MappingIterator<T, R>(
|
||||
private val upstream: Iterator<T>,
|
||||
private val mapper: (T) -> R,
|
||||
) : Iterator<R> {
|
||||
|
||||
override fun hasNext(): Boolean = upstream.hasNext()
|
||||
|
||||
override fun next(): R = mapper(upstream.next())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.zip
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.core.util.ext.children
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.Deflater
|
||||
@@ -90,7 +91,7 @@ class ZipOutput(
|
||||
}
|
||||
putNextEntry(entry)
|
||||
closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
fileToZip.children().forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
} else {
|
||||
|
||||
32
app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipPool.kt
Normal file
32
app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipPool.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.LruCache
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
|
||||
|
||||
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue)
|
||||
oldValue.closeQuietly()
|
||||
}
|
||||
|
||||
override fun create(key: String): ZipFile {
|
||||
return ZipFile(File(key), ZipFile.OPEN_READ)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@WorkerThread
|
||||
operator fun get(uri: Uri): Source {
|
||||
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
|
||||
"Cannot obtain zip by \"$uri\""
|
||||
}
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
return zip.getInputStream(entry).source()
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ class ChaptersFragment :
|
||||
.manga(viewModel.manga.value ?: return)
|
||||
.state(ReaderState(item.chapter.id, 0, 0))
|
||||
.build(),
|
||||
scaleUpActivityOptionsOf(view),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.transition.AutoTransition
|
||||
import android.transition.Slide
|
||||
import android.transition.TransitionManager
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -17,6 +20,8 @@ import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
@@ -33,12 +38,11 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ViewBadge
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -48,10 +52,8 @@ import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
import org.koitharu.kotatsu.details.ui.adapter.branchAD
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
|
||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -157,7 +159,7 @@ class DetailsActivity :
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
R.id.button_dropdown -> showBranchPopupMenu()
|
||||
R.id.button_dropdown -> showBranchPopupMenu(v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,20 +277,28 @@ class DetailsActivity :
|
||||
viewBadge.counter = newChapters
|
||||
}
|
||||
|
||||
private fun showBranchPopupMenu() {
|
||||
var dialog: DialogInterface? = null
|
||||
val listener = OnListItemClickListener<MangaBranch> { item, _ ->
|
||||
viewModel.setSelectedBranch(item.name)
|
||||
dialog?.dismiss()
|
||||
private fun showBranchPopupMenu(v: View) {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
val branches = viewModel.branches.value
|
||||
for ((i, branch) in branches.withIndex()) {
|
||||
val title = buildSpannedString {
|
||||
append(branch.name ?: getString(R.string.system_default))
|
||||
append(' ')
|
||||
append(' ')
|
||||
inSpans(
|
||||
ForegroundColorSpan(v.context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY)),
|
||||
RelativeSizeSpan(0.74f),
|
||||
) {
|
||||
append(branch.count.toString())
|
||||
}
|
||||
}
|
||||
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
|
||||
}
|
||||
dialog = RecyclerViewAlertDialog.Builder<MangaBranch>(this)
|
||||
.addAdapterDelegate(branchAD(listener))
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setTitle(R.string.translations)
|
||||
.setItems(viewModel.branches.value)
|
||||
.create()
|
||||
.also { it.show() }
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun openReader(isIncognitoMode: Boolean) {
|
||||
|
||||
@@ -119,7 +119,6 @@ class DetailsFragment :
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
startActivity(
|
||||
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
|
||||
scaleUpActivityOptionsOf(view),
|
||||
)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -233,7 +232,7 @@ class DetailsFragment :
|
||||
coil, viewLifecycleOwner,
|
||||
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
||||
) { item, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, item), scaleUpActivityOptionsOf(view))
|
||||
startActivity(DetailsActivity.newIntent(view.context, item))
|
||||
},
|
||||
).also { rv.adapter = it }
|
||||
adapter.items = related
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
@@ -62,7 +62,7 @@ class DetailsMenuProvider(
|
||||
|
||||
R.id.action_favourite -> {
|
||||
viewModel.manga.value?.let {
|
||||
FavouriteCategoriesSheet.show(activity.supportFragmentManager, it)
|
||||
FavouriteSheet.show(activity.supportFragmentManager, it)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||
|
||||
class BranchesAdapter(
|
||||
list: List<MangaBranch>,
|
||||
listener: OnListItemClickListener<MangaBranch>,
|
||||
) : ListDelegationAdapter<List<MangaBranch>>() {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(branchAD(listener))
|
||||
items = list
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class ScrobblingInfoSheet :
|
||||
viewModel.updateScrobbling(
|
||||
index = scrobblerIndex,
|
||||
rating = requireViewBinding().ratingBar.rating / requireViewBinding().ratingBar.numStars,
|
||||
status = enumValues<ScrobblingStatus>().getOrNull(position),
|
||||
status = ScrobblingStatus.entries.getOrNull(position),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class ScrobblingInfoSheet :
|
||||
viewModel.updateScrobbling(
|
||||
index = scrobblerIndex,
|
||||
rating = rating / ratingBar.numStars,
|
||||
status = enumValues<ScrobblingStatus>().getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition),
|
||||
status = ScrobblingStatus.entries.getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.work.WorkInfo
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
@@ -53,6 +54,7 @@ fun downloadItemAD(
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
transformations(TrimTransformation())
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.util.Date
|
||||
import java.util.LinkedList
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
@@ -181,16 +182,34 @@ class DownloadsViewModel @Inject constructor(
|
||||
if (isEmpty()) {
|
||||
return emptyStateList()
|
||||
}
|
||||
val destination = ArrayList<ListModel>((size * 1.4).toInt())
|
||||
val queued = LinkedList<ListModel>()
|
||||
val running = LinkedList<ListModel>()
|
||||
val destination = ArrayDeque<ListModel>((size * 1.4).toInt())
|
||||
var prevDate: DateTimeAgo? = null
|
||||
for (item in this) {
|
||||
val date = timeAgo(item.timestamp)
|
||||
if (prevDate != date) {
|
||||
destination += ListHeader(date)
|
||||
when (item.workState) {
|
||||
WorkInfo.State.RUNNING -> running += item
|
||||
WorkInfo.State.BLOCKED,
|
||||
WorkInfo.State.ENQUEUED -> queued += item
|
||||
|
||||
else -> {
|
||||
val date = timeAgo(item.timestamp)
|
||||
if (prevDate != date) {
|
||||
destination += ListHeader(date)
|
||||
}
|
||||
prevDate = date
|
||||
destination += item
|
||||
}
|
||||
}
|
||||
prevDate = date
|
||||
destination += item
|
||||
}
|
||||
if (running.isNotEmpty()) {
|
||||
running.addFirst(ListHeader(R.string.in_progress))
|
||||
}
|
||||
destination.addAll(0, running)
|
||||
if (queued.isNotEmpty()) {
|
||||
queued.addFirst(ListHeader(R.string.queued))
|
||||
}
|
||||
destination.addAll(0, queued)
|
||||
return destination
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.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.move
|
||||
import java.util.Collections
|
||||
@@ -20,6 +24,7 @@ import javax.inject.Inject
|
||||
@Reusable
|
||||
class MangaSourcesRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val dao: MangaSourcesDao
|
||||
@@ -36,11 +41,13 @@ class MangaSourcesRepository @Inject constructor(
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
suspend fun getEnabledSources(): List<MangaSource> {
|
||||
return dao.findAllEnabled().toSources()
|
||||
return dao.findAllEnabled().toSources(settings.isNsfwContentDisabled)
|
||||
}
|
||||
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = dao.observeEnabled().map {
|
||||
it.toSources()
|
||||
fun observeEnabledSources(): Flow<List<MangaSource>> = observeIsNsfwDisabled().flatMapLatest { skipNsfw ->
|
||||
dao.observeEnabled().map {
|
||||
it.toSources(skipNsfw)
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||
@@ -137,14 +144,21 @@ class MangaSourcesRepository @Inject constructor(
|
||||
return dao.findAll().isEmpty()
|
||||
}
|
||||
|
||||
private fun List<MangaSourceEntity>.toSources(): List<MangaSource> {
|
||||
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(size)
|
||||
for (entity in this) {
|
||||
val source = MangaSource(entity.source)
|
||||
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
|
||||
continue
|
||||
}
|
||||
if (source in remoteSources) {
|
||||
result.add(source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
|
||||
isNsfwContentDisabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class ExploreFragment :
|
||||
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga), scaleUpActivityOptionsOf(view))
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
}
|
||||
with(binding.recyclerView) {
|
||||
adapter = exploreAdapter
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.explore.ui.model
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class RecommendationsItem(
|
||||
data class RecommendationsItem(
|
||||
val manga: Manga
|
||||
) : ListModel {
|
||||
|
||||
@@ -12,18 +12,4 @@ class RecommendationsItem(
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is RecommendationsItem
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as RecommendationsItem
|
||||
|
||||
return manga == other.manga
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 31 * manga.hashCode()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.favourites.domain.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
|
||||
class Cover(
|
||||
val url: String,
|
||||
@@ -8,7 +9,7 @@ class Cover(
|
||||
) {
|
||||
|
||||
val mangaSource: MangaSource?
|
||||
get() = if (source.isEmpty()) null else MangaSource.values().find { it.name == source }
|
||||
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FavouriteCategoriesSheet :
|
||||
class FavouriteSheet :
|
||||
BaseAdaptiveSheet<SheetFavoriteCategoriesBinding>(),
|
||||
OnListItemClickListener<MangaCategoryItem> {
|
||||
|
||||
@@ -53,7 +53,7 @@ class FavouriteCategoriesSheet :
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaCategoryItem, view: View) {
|
||||
viewModel.setChecked(item.id, !item.isChecked)
|
||||
viewModel.setChecked(item.category.id, !item.isChecked)
|
||||
}
|
||||
|
||||
private fun onContentChanged(categories: List<ListModel>) {
|
||||
@@ -72,7 +72,7 @@ class FavouriteCategoriesSheet :
|
||||
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
|
||||
|
||||
fun show(fm: FragmentManager, manga: Collection<Manga>) =
|
||||
FavouriteCategoriesSheet().withArgs(1) {
|
||||
FavouriteSheet().withArgs(1) {
|
||||
putParcelableArrayList(
|
||||
KEY_MANGA_LIST,
|
||||
manga.mapTo(ArrayList(manga.size)) {
|
||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet.Companion.KEY_MANGA_LIST
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet.Companion.KEY_MANGA_LIST
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -37,8 +37,7 @@ class MangaCategoriesViewModel @Inject constructor(
|
||||
add(header)
|
||||
all.mapTo(this) {
|
||||
MangaCategoryItem(
|
||||
id = it.id,
|
||||
name = it.title,
|
||||
category = it,
|
||||
isChecked = it.id in checked,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
|
||||
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun mangaCategoryAD(
|
||||
clickListener: OnListItemClickListener<MangaCategoryItem>,
|
||||
) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCheckableNewBinding>(
|
||||
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
|
||||
) = adapterDelegateViewBinding<MangaCategoryItem, ListModel, ItemCategoryCheckableBinding>(
|
||||
{ inflater, parent -> ItemCategoryCheckableBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
@@ -19,9 +20,9 @@ fun mangaCategoryAD(
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
with(binding.root) {
|
||||
text = item.name
|
||||
setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
|
||||
}
|
||||
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads)
|
||||
binding.textViewTitle.text = item.category.title
|
||||
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled
|
||||
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories.select.model
|
||||
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class MangaCategoryItem(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
data class MangaCategoryItem(
|
||||
val category: FavouriteCategory,
|
||||
val isChecked: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaCategoryItem && other.id == id
|
||||
return other is MangaCategoryItem && other.category.id == category.id
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? {
|
||||
@@ -20,22 +20,4 @@ class MangaCategoryItem(
|
||||
super.getChangePayload(previousState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as MangaCategoryItem
|
||||
|
||||
if (id != other.id) return false
|
||||
if (name != other.name) return false
|
||||
return isChecked == other.isChecked
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + isChecked.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class FavouritesListMenuProvider(
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
if (menuItem.groupId == R.id.group_order) {
|
||||
val order = enumValues<SortOrder>()[menuItem.order]
|
||||
val order = SortOrder.entries[menuItem.order]
|
||||
viewModel.setSortOrder(order)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.filter.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
class FilterHeaderModel(
|
||||
|
||||
@@ -12,5 +12,5 @@ enum class HistoryOrder(
|
||||
PROGRESS(R.string.progress),
|
||||
ALPHABETIC(R.string.by_name);
|
||||
|
||||
fun isGroupingSupported() = this == UPDATED || this == CREATED
|
||||
fun isGroupingSupported() = this == UPDATED || this == CREATED || this == PROGRESS
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
|
||||
|
||||
class HistoryListAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: MangaListListener
|
||||
) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer {
|
||||
listener: MangaListListener,
|
||||
sizeResolver: ItemSizeResolver,
|
||||
) : MangaListAdapter(coil, lifecycleOwner, listener, sizeResolver), FastScroller.SectionIndexer {
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
val list = items
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -55,5 +56,10 @@ class HistoryListFragment : MangaListFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateAdapter() = HistoryListAdapter(coil, viewLifecycleOwner, this)
|
||||
override fun onCreateAdapter() = HistoryListAdapter(
|
||||
coil,
|
||||
viewLifecycleOwner,
|
||||
this,
|
||||
DynamicItemSizeResolver(resources, settings, adjustWidth = false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class HistoryListMenuProvider(
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_history, menu)
|
||||
val subMenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||
for (order in HistoryOrder.values()) {
|
||||
for (order in HistoryOrder.entries) {
|
||||
subMenu.add(R.id.group_order, Menu.NONE, order.ordinal, order.titleResId)
|
||||
}
|
||||
subMenu.setGroupCheckable(R.id.group_order, true, true)
|
||||
@@ -31,7 +31,7 @@ class HistoryListMenuProvider(
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
if (menuItem.groupId == R.id.group_order) {
|
||||
val order = enumValues<HistoryOrder>()[menuItem.order]
|
||||
val order = HistoryOrder.entries[menuItem.order]
|
||||
viewModel.setSortOrder(order)
|
||||
return true
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class HistoryListMenuProvider(
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val order = viewModel.sortOrder.value ?: return
|
||||
val order = viewModel.sortOrder.value
|
||||
menu.findItem(R.id.action_order)?.subMenu?.forEach { item ->
|
||||
if (item.order == order.ordinal) {
|
||||
item.isChecked = true
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
@@ -130,14 +131,17 @@ class HistoryListViewModel @Inject constructor(
|
||||
mode: ListMode,
|
||||
): List<ListModel> {
|
||||
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
|
||||
var prevDate: DateTimeAgo? = null
|
||||
val order = sortOrder.value
|
||||
var prevHeader: ListHeader? = null
|
||||
for ((manga, history) in list) {
|
||||
if (grouped) {
|
||||
val date = timeAgo(history.updatedAt)
|
||||
if (prevDate != date) {
|
||||
result += ListHeader(date)
|
||||
val header = history.header(order)
|
||||
if (header != prevHeader) {
|
||||
if (header != null) {
|
||||
result += header
|
||||
}
|
||||
prevHeader = header
|
||||
}
|
||||
prevDate = date
|
||||
}
|
||||
result += when (mode) {
|
||||
ListMode.LIST -> manga.toListModel(extraProvider)
|
||||
@@ -148,6 +152,21 @@ class HistoryListViewModel @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun MangaHistory.header(order: HistoryOrder): ListHeader? = when (order) {
|
||||
HistoryOrder.UPDATED -> ListHeader(timeAgo(updatedAt))
|
||||
HistoryOrder.CREATED -> ListHeader(timeAgo(createdAt))
|
||||
HistoryOrder.PROGRESS -> ListHeader(
|
||||
when (percent) {
|
||||
1f -> R.string.status_completed
|
||||
in 0f..0.01f -> R.string.status_planned
|
||||
in 0f..1f -> R.string.status_reading
|
||||
else -> R.string.unknown
|
||||
},
|
||||
)
|
||||
|
||||
HistoryOrder.ALPHABETIC -> null
|
||||
}
|
||||
|
||||
private fun timeAgo(date: Date): DateTimeAgo {
|
||||
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
|
||||
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager
|
||||
@@ -41,7 +42,7 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
@@ -49,6 +50,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -69,6 +71,9 @@ abstract class MangaListFragment :
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var listAdapter: MangaListAdapter? = null
|
||||
private var paginationListener: PaginationScrollListener? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
@@ -147,7 +152,7 @@ abstract class MangaListFragment :
|
||||
override fun onReadClick(manga: Manga, view: View) {
|
||||
if (selectionController?.onItemClick(manga.id) != true) {
|
||||
val intent = IntentBuilder(view.context).manga(manga).build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +200,7 @@ abstract class MangaListFragment :
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
listener = this,
|
||||
sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,7 +289,7 @@ abstract class MangaListFragment :
|
||||
}
|
||||
|
||||
R.id.action_favourite -> {
|
||||
FavouriteCategoriesSheet.show(childFragmentManager, selectedItems)
|
||||
FavouriteSheet.show(childFragmentManager, selectedItems)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
abstract class MangaListViewModel(
|
||||
settings: AppSettings,
|
||||
private val settings: AppSettings,
|
||||
private val downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : BaseViewModel() {
|
||||
|
||||
@@ -46,4 +46,10 @@ abstract class MangaListViewModel(
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
|
||||
filterNot { it.isNsfw }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ enum class ListItemType {
|
||||
MANGA_LIST,
|
||||
MANGA_LIST_DETAILED,
|
||||
MANGA_GRID,
|
||||
MANGA_NESTED_GROUP,
|
||||
FOOTER_LOADING,
|
||||
FOOTER_ERROR,
|
||||
STATE_LOADING,
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
fun mangaGridItemAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
sizeResolver: ItemSizeResolver?,
|
||||
sizeResolver: ItemSizeResolver,
|
||||
clickListener: OnListItemClickListener<Manga>,
|
||||
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
|
||||
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
|
||||
@@ -36,7 +36,7 @@ fun mangaGridItemAD(
|
||||
itemView.setOnLongClickListener {
|
||||
clickListener.onItemLongClick(item.manga, it)
|
||||
}
|
||||
sizeResolver?.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
|
||||
sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.title
|
||||
|
||||
@@ -4,17 +4,19 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
|
||||
|
||||
open class MangaListAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: MangaListListener,
|
||||
sizeResolver: ItemSizeResolver,
|
||||
) : BaseListAdapter<ListModel>() {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
|
||||
addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, null, listener))
|
||||
addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listener))
|
||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener))
|
||||
|
||||
@@ -21,7 +21,7 @@ class TypedListSpacingDecoration(
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val itemType = parent.getChildViewHolder(view)?.itemViewType?.let {
|
||||
ListItemType.values().getOrNull(it)
|
||||
ListItemType.entries.getOrNull(it)
|
||||
}
|
||||
when (itemType) {
|
||||
ListItemType.FILTER_SORT,
|
||||
@@ -44,11 +44,12 @@ class TypedListSpacingDecoration(
|
||||
ListItemType.EXPLORE_SOURCE_GRID,
|
||||
ListItemType.EXPLORE_SOURCE_LIST,
|
||||
ListItemType.EXPLORE_SUGGESTION,
|
||||
ListItemType.MANGA_NESTED_GROUP,
|
||||
null -> outRect.set(0)
|
||||
|
||||
ListItemType.TIP -> outRect.set(0) // TODO
|
||||
ListItemType.HINT_EMPTY -> outRect.set(0) // TODO
|
||||
ListItemType.FEED -> outRect.set(0) // TODO
|
||||
ListItemType.FEED -> outRect.set(spacingList, 0, spacingList, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
|
||||
R.id.button_close -> closeSelf()
|
||||
R.id.button_open -> startActivity(
|
||||
DetailsActivity.newIntent(v.context, manga),
|
||||
scaleUpActivityOptionsOf(requireView()),
|
||||
)
|
||||
|
||||
R.id.textView_author -> startActivity(
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.res.Resources
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -14,7 +13,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.ui.util.ReadingProgressView
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DynamicItemSizeResolver(resources: Resources, private val settings: AppSettings) : ItemSizeResolver {
|
||||
class DynamicItemSizeResolver(
|
||||
resources: Resources,
|
||||
private val settings: AppSettings,
|
||||
private val adjustWidth: Boolean,
|
||||
) : ItemSizeResolver {
|
||||
|
||||
private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
|
||||
private val scaleFactor: Float
|
||||
@@ -72,8 +75,12 @@ class DynamicItemSizeResolver(resources: Resources, private val settings: AppSet
|
||||
fun update() {
|
||||
val newWidth = cellWidth
|
||||
textView?.adjustTextAppearance(newWidth)
|
||||
view.updateLayoutParams {
|
||||
width = newWidth
|
||||
if (adjustWidth) {
|
||||
val lp = view.layoutParams
|
||||
if (lp.width != newWidth) {
|
||||
lp.width = newWidth
|
||||
view.layoutParams = lp
|
||||
}
|
||||
}
|
||||
progressView?.adjustSize(newWidth)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.CompositeMutex
|
||||
import org.koitharu.kotatsu.core.util.ext.children
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.filterWith
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
@@ -128,9 +130,6 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
|
||||
val files = getAllFiles()
|
||||
if (files.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return channelFlow {
|
||||
for (file in files) {
|
||||
launch {
|
||||
@@ -172,7 +171,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
val dirs = storageManager.getWriteableDirs()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
dirs.flatMap { dir ->
|
||||
dir.listFiles(TempFileFilter())?.toList().orEmpty()
|
||||
dir.children().filterWith(TempFileFilter())
|
||||
}.forEach { file ->
|
||||
file.deleteRecursively()
|
||||
}
|
||||
@@ -189,7 +188,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun getRawList(): ArrayList<LocalManga> {
|
||||
val files = getAllFiles()
|
||||
val files = getAllFiles().toList() // TODO remove toList()
|
||||
return coroutineScope {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
files.map { file ->
|
||||
@@ -200,8 +199,8 @@ class LocalMangaRepository @Inject constructor(
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
|
||||
dir.listFiles()?.toList().orEmpty()
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
||||
dir.children()
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
@@ -67,7 +68,7 @@ class MangaIndex(source: String?) {
|
||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||
coverUrl = json.getString("cover"),
|
||||
state = json.getStringOrNull("state")?.let { stateString ->
|
||||
MangaState.values().find { it.name == stateString }
|
||||
MangaState.entries.find(stateString)
|
||||
},
|
||||
description = json.getStringOrNull("description"),
|
||||
tags = json.getJSONArray("tags").mapJSONToSet { x ->
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileFilter
|
||||
import java.io.FilenameFilter
|
||||
|
||||
class TempFileFilter : FilenameFilter {
|
||||
class TempFileFilter : FilenameFilter, FileFilter {
|
||||
|
||||
override fun accept(dir: File, name: String): Boolean {
|
||||
return name.endsWith(".tmp", ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun accept(file: File): Boolean {
|
||||
return file.name.endsWith(".tmp", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||
@@ -88,7 +89,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
val file = chapter.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
file.listFilesRecursive(ImageFileFilter())
|
||||
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(
|
||||
@@ -104,7 +105,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
.asSequence()
|
||||
.filter { x -> !x.isDirectory }
|
||||
.map { it.name }
|
||||
.toListSorted(org.koitharu.kotatsu.core.util.AlphanumComparator())
|
||||
.toListSorted(AlphanumComparator())
|
||||
.map {
|
||||
val pageUri = zipUri(file, it)
|
||||
MangaPage(
|
||||
@@ -121,7 +122,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
||||
|
||||
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
|
||||
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
|
||||
private fun findFirstImageEntry(): String? {
|
||||
val filter = ImageFileFilter()
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ class ImportWorker @AssistedInject constructor(
|
||||
val result = runCatchingCancellable {
|
||||
importer.import(uri).manga
|
||||
}
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(uri.hashCode(), notification)
|
||||
if (notificationManager.areNotificationsEnabled()) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(uri.hashCode(), notification)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
@@ -39,6 +40,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.ramAvailable
|
||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
||||
import org.koitharu.kotatsu.core.zip.ZipPool
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
@@ -47,7 +49,6 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import java.io.File
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -70,10 +71,12 @@ class PageLoader @Inject constructor(
|
||||
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
|
||||
|
||||
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
|
||||
private val semaphore = Semaphore(3)
|
||||
private val convertLock = Mutex()
|
||||
private val prefetchLock = Mutex()
|
||||
private var repository: MangaRepository? = null
|
||||
private val prefetchQueue = LinkedList<MangaPage>()
|
||||
private val zipPool = ZipPool(2)
|
||||
private val counter = AtomicInteger(0)
|
||||
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||
|
||||
@@ -81,6 +84,7 @@ class PageLoader @Inject constructor(
|
||||
synchronized(tasks) {
|
||||
tasks.clear()
|
||||
}
|
||||
zipPool.evictAll()
|
||||
}
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
@@ -190,20 +194,15 @@ class PageLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
|
||||
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File = semaphore.withPermit {
|
||||
val pageUrl = getPageUrl(page)
|
||||
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
|
||||
val uri = Uri.parse(pageUrl)
|
||||
return if (CbzFilter.isUriSupported(uri)) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(uri.schemeSpecificPart)
|
||||
}.use { zip ->
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry)
|
||||
}.use {
|
||||
cache.put(pageUrl, it.source())
|
||||
}
|
||||
zipPool[uri]
|
||||
}.use {
|
||||
cache.put(pageUrl, it)
|
||||
}
|
||||
} else {
|
||||
val request = createPageRequest(page, pageUrl)
|
||||
|
||||
@@ -44,7 +44,7 @@ class PageSaveHelper @Inject constructor(
|
||||
val pageFile = pageLoader.loadPage(page, force = false)
|
||||
val proposedName = getProposedFileName(pageUrl, pageFile)
|
||||
val destination = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine<Uri> { cont ->
|
||||
suspendCancellableCoroutine { cont ->
|
||||
continuation = cont
|
||||
saveLauncher.launch(proposedName)
|
||||
}.also {
|
||||
|
||||
@@ -101,7 +101,7 @@ class ReaderViewModel @Inject constructor(
|
||||
val manga: DoubleManga?
|
||||
get() = mangaData.value
|
||||
|
||||
val readerAnimation = settings.observeAsStateFlow(
|
||||
val pageAnimation = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_ANIMATION,
|
||||
valueProducer = { readerAnimation },
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.os.Bundle
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
@@ -54,6 +56,10 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
|
||||
"Adapter was not created or already destroyed"
|
||||
}
|
||||
|
||||
protected fun isAnimationEnabled(): Boolean {
|
||||
return context?.isAnimationsEnabled == true && viewModel.pageAnimation.value != ReaderAnimation.NONE
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
abstract fun switchPageBy(delta: Int)
|
||||
|
||||
@@ -11,8 +11,8 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.resetTransformations
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
@@ -48,8 +49,12 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
}
|
||||
|
||||
viewModel.readerAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = if (it) ReversedPageAnimTransformer() else null
|
||||
viewModel.pageAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = when (it) {
|
||||
ReaderAnimation.NONE -> NoAnimPageTransformer()
|
||||
ReaderAnimation.DEFAULT -> null
|
||||
ReaderAnimation.ADVANCED -> ReversedPageAnimTransformer()
|
||||
}
|
||||
binding.pager.setPageTransformer(transformer)
|
||||
if (transformer == null) {
|
||||
binding.pager.recyclerView?.children?.forEach { v ->
|
||||
@@ -74,7 +79,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(currentItem - delta, context.isAnimationsEnabled)
|
||||
setCurrentItem(currentItem - delta, isAnimationEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(
|
||||
reversed(position),
|
||||
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
|
||||
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.view.View
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
class NoAnimPageTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(page: View, position: Float) {
|
||||
page.translationX = when {
|
||||
position in -0.5f..0.5f -> -position * page.width.toFloat()
|
||||
position > 0 -> page.width.toFloat()
|
||||
else -> -page.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.resetTransformations
|
||||
@@ -47,8 +47,12 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
}
|
||||
|
||||
viewModel.readerAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = if (it) PageAnimTransformer() else null
|
||||
viewModel.pageAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = when (it) {
|
||||
ReaderAnimation.NONE -> NoAnimPageTransformer()
|
||||
ReaderAnimation.DEFAULT -> null
|
||||
ReaderAnimation.ADVANCED -> PageAnimTransformer()
|
||||
}
|
||||
binding.pager.setPageTransformer(transformer)
|
||||
if (transformer == null) {
|
||||
binding.pager.recyclerView?.children?.forEach { view ->
|
||||
@@ -95,7 +99,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(currentItem + delta, context.isAnimationsEnabled)
|
||||
setCurrentItem(currentItem + delta, isAnimationEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +107,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(
|
||||
position,
|
||||
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
|
||||
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
|
||||
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
@@ -111,7 +110,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().recyclerView) {
|
||||
if (context.isAnimationsEnabled) {
|
||||
if (isAnimationEnabled()) {
|
||||
smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator)
|
||||
} else {
|
||||
nestedScrollBy(0, (height * 0.9).toInt() * delta)
|
||||
|
||||
@@ -57,17 +57,19 @@ class MangaPageFetcher(
|
||||
private suspend fun loadPage(pageUrl: String): SourceResult {
|
||||
val uri = pageUrl.toUri()
|
||||
return if (CbzFilter.isUriSupported(uri)) {
|
||||
val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) }
|
||||
val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) }
|
||||
return SourceResult(
|
||||
source = ImageSource(
|
||||
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||
|
||||
@@ -102,7 +102,7 @@ class PagesThumbnailsSheet :
|
||||
} else {
|
||||
val state = ReaderState(item.page.chapterId, item.page.index, 0)
|
||||
val intent = IntentBuilder(view.context).manga(viewModel.manga).state(state).build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
startActivity(intent)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -66,7 +67,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
private var randomJob: Job? = null
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
mangaList.map { it?.skipNsfwIfNeeded() },
|
||||
listMode,
|
||||
listError,
|
||||
hasNextPage,
|
||||
|
||||
@@ -84,7 +84,7 @@ class ScrobblerConfigViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
val grouped = list.groupBy { it.status }
|
||||
val statuses = enumValues<ScrobblingStatus>()
|
||||
val statuses = ScrobblingStatus.entries
|
||||
val result = ArrayList<ListModel>(list.size + statuses.size)
|
||||
for (st in statuses) {
|
||||
val subList = grouped[st]
|
||||
@@ -102,7 +102,7 @@ class ScrobblerConfigViewModel @Inject constructor(
|
||||
): ScrobblerService {
|
||||
val serviceId = savedStateHandle.get<Int>(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0
|
||||
if (serviceId != 0) {
|
||||
return enumValues<ScrobblerService>().first { it.id == serviceId }
|
||||
return ScrobblerService.entries.first { it.id == serviceId }
|
||||
}
|
||||
val uri = savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA)
|
||||
return when (uri.host) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.search.domain
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
@@ -8,19 +7,18 @@ import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -30,34 +28,22 @@ class MangaSearchRepository @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val recentSuggestions: SearchRecentSuggestions,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
|
||||
flow {
|
||||
emitAll(sourcesRepository.getEnabledSources().asFlow())
|
||||
}.flatMapMerge(concurrency) { source ->
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(source).getList(
|
||||
offset = 0,
|
||||
query = query,
|
||||
)
|
||||
}.getOrElse {
|
||||
emptyList()
|
||||
}.asFlow()
|
||||
}.filter {
|
||||
match(it, query)
|
||||
}
|
||||
|
||||
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
|
||||
if (query.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
return if (source != null) {
|
||||
db.mangaDao.searchByTitle("%$query%", source.name, limit)
|
||||
} else {
|
||||
db.mangaDao.searchByTitle("%$query%", limit)
|
||||
}.map { it.toManga() }
|
||||
}.let {
|
||||
if (skipNsfw) it.filterNot { x -> x.manga.isNsfw } else it
|
||||
}
|
||||
.map { it.toManga() }
|
||||
.sortedBy { x -> x.title.levenshteinDistance(query) }
|
||||
}
|
||||
|
||||
@@ -67,7 +53,7 @@ class MangaSearchRepository @Inject constructor(
|
||||
): List<String> = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.query(
|
||||
MangaSuggestionsProvider.QUERY_URI,
|
||||
SUGGESTION_PROJECTION,
|
||||
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
|
||||
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
|
||||
arrayOf("%$query%"),
|
||||
"date DESC",
|
||||
@@ -102,8 +88,11 @@ class MangaSearchRepository @Inject constructor(
|
||||
if (query.length < 3) {
|
||||
return emptyList()
|
||||
}
|
||||
val skipNsfw = settings.isNsfwContentDisabled
|
||||
val sources = sourcesRepository.allMangaSources
|
||||
.filter { x -> x.title.contains(query, ignoreCase = true) }
|
||||
.filter { x ->
|
||||
(x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
|
||||
}
|
||||
return if (limit == 0) {
|
||||
sources
|
||||
} else {
|
||||
@@ -130,33 +119,10 @@ class MangaSearchRepository @Inject constructor(
|
||||
suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.query(
|
||||
MangaSuggestionsProvider.QUERY_URI,
|
||||
SUGGESTION_PROJECTION,
|
||||
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
|
||||
null,
|
||||
arrayOfNulls(1),
|
||||
null,
|
||||
)?.use { cursor -> cursor.count } ?: 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private val REGEX_SPACE = Regex("\\s+")
|
||||
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
fun match(manga: Manga, query: String): Boolean {
|
||||
val words = HashSet<String>()
|
||||
words += manga.title.lowercase().split(REGEX_SPACE)
|
||||
words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
|
||||
val words2 = query.lowercase().split(REGEX_SPACE).toSet()
|
||||
for (w in words) {
|
||||
for (w2 in words2) {
|
||||
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)
|
||||
if (diff < 0.5) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesSheet
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteSheet
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
@@ -60,11 +60,12 @@ class MultiSearchActivity :
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySearchMultiBinding.inflate(layoutInflater))
|
||||
window.statusBarColor = ContextCompat.getColor(this, R.color.dim_statusbar)
|
||||
title = viewModel.query
|
||||
|
||||
val itemCLickListener = OnListItemClickListener<MultiSearchListModel> { item, view ->
|
||||
startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value))
|
||||
startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query))
|
||||
}
|
||||
val sizeResolver = DynamicItemSizeResolver(resources, settings)
|
||||
val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true)
|
||||
val selectionDecoration = MangaSelectionDecoration(this)
|
||||
selectionController = ListSelectionController(
|
||||
activity = this,
|
||||
@@ -88,7 +89,6 @@ class MultiSearchActivity :
|
||||
setSubtitle(R.string.search_results)
|
||||
}
|
||||
|
||||
viewModel.query.observe(this) { title = it }
|
||||
viewModel.list.observe(this) { adapter.items = it }
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView))
|
||||
@@ -118,7 +118,7 @@ class MultiSearchActivity :
|
||||
override fun onReadClick(manga: Manga, view: View) {
|
||||
if (!selectionController.onItemClick(manga.id)) {
|
||||
val intent = IntentBuilder(this).manga(manga).build()
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class MultiSearchActivity :
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) {
|
||||
viewModel.doSearch(viewModel.query.value)
|
||||
viewModel.retry()
|
||||
}
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
||||
@@ -159,7 +159,7 @@ class MultiSearchActivity :
|
||||
}
|
||||
|
||||
R.id.action_favourite -> {
|
||||
FavouriteCategoriesSheet.show(supportFragmentManager, collectSelectedItems())
|
||||
FavouriteSheet.show(supportFragmentManager, collectSelectedItems())
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
package org.koitharu.kotatsu.search.ui.multi
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
@@ -48,15 +50,17 @@ class MultiSearchViewModel @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private val listData = MutableStateFlow<List<MultiSearchListModel>>(emptyList())
|
||||
private val loadingData = MutableStateFlow(false)
|
||||
val onDownloadStarted = MutableEventFlow<Unit>()
|
||||
val query = savedStateHandle.get<String>(MultiSearchActivity.EXTRA_QUERY).orEmpty()
|
||||
|
||||
private val retryCounter = MutableStateFlow(0)
|
||||
private val listData = retryCounter.flatMapLatest {
|
||||
searchImpl(query).withLoading().withErrorHandling()
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val query = MutableStateFlow(savedStateHandle.get<String>(MultiSearchActivity.EXTRA_QUERY).orEmpty())
|
||||
val list: StateFlow<List<ListModel>> = combine(
|
||||
listData,
|
||||
loadingData,
|
||||
listData.filterNotNull(),
|
||||
isLoading,
|
||||
) { list, loading ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
@@ -76,13 +80,10 @@ class MultiSearchViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
doSearch(query.value)
|
||||
}
|
||||
|
||||
fun getItems(ids: Set<Long>): Set<Manga> {
|
||||
val snapshot = listData.value ?: return emptySet()
|
||||
val result = HashSet<Manga>(ids.size)
|
||||
listData.value.forEach { x ->
|
||||
snapshot.forEach { x ->
|
||||
for (item in x.list) {
|
||||
if (item.id in ids) {
|
||||
result.add(item.manga)
|
||||
@@ -92,21 +93,8 @@ class MultiSearchViewModel @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
fun doSearch(q: String) {
|
||||
val prevJob = searchJob
|
||||
searchJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
try {
|
||||
listData.value = emptyList()
|
||||
loadingData.value = true
|
||||
query.value = q
|
||||
searchImpl(q)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} finally {
|
||||
loadingData.value = false
|
||||
}
|
||||
}
|
||||
fun retry() {
|
||||
retryCounter.value = retryCounter.value + 1
|
||||
}
|
||||
|
||||
fun download(items: Set<Manga>) {
|
||||
@@ -116,13 +104,14 @@ class MultiSearchViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchImpl(q: String) = coroutineScope {
|
||||
@CheckResult
|
||||
private fun searchImpl(q: String): Flow<List<MultiSearchListModel>> = channelFlow {
|
||||
val sources = sourcesRepository.getEnabledSources()
|
||||
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
|
||||
val deferredList = sources.map { source ->
|
||||
async(dispatcher) {
|
||||
runCatchingCancellable {
|
||||
withTimeout(8_000) {
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
for (source in sources) {
|
||||
launch {
|
||||
val item = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
mangaRepositoryFactory.create(source).getList(offset = 0, query = q)
|
||||
.toUi(ListMode.GRID, extraProvider)
|
||||
}
|
||||
@@ -139,12 +128,11 @@ class MultiSearchViewModel @Inject constructor(
|
||||
MultiSearchListModel(source, true, emptyList(), error)
|
||||
},
|
||||
)
|
||||
if (item != null) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (deferred in deferredList) {
|
||||
deferred.await()?.let { item ->
|
||||
listData.update { x -> x + item }
|
||||
}
|
||||
}
|
||||
}
|
||||
}.runningFold<MultiSearchListModel, List<MultiSearchListModel>?>(null) { list, item -> list.orEmpty() + item }
|
||||
.filterNotNull()
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ fun searchResultsAD(
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.title
|
||||
binding.buttonMore.isVisible = item.hasMore
|
||||
adapter.notifyDataSetChanged()
|
||||
adapter.items = item.list
|
||||
adapter.notifyDataSetChanged()
|
||||
binding.recyclerView.isGone = item.list.isEmpty()
|
||||
binding.textViewError.textAndVisible = item.error?.getDisplayMessage(context.resources)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class AppearanceSettingsFragment :
|
||||
}
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
||||
entryValues = ListMode.values().names()
|
||||
entryValues = ListMode.entries.names()
|
||||
setDefaultValueCompat(ListMode.GRID.name)
|
||||
}
|
||||
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
|
||||
@@ -102,7 +102,7 @@ class AppearanceSettingsFragment :
|
||||
}
|
||||
|
||||
private fun initLocalePicker(preference: ListPreference) {
|
||||
val locales = resources.getLocalesConfig()
|
||||
val locales = preference.context.getLocalesConfig()
|
||||
.toList()
|
||||
.sortedWith(LocaleComparator(preference.context))
|
||||
preference.entries = Array(locales.size + 1) { i ->
|
||||
|
||||
@@ -29,12 +29,7 @@ class NetworkSettingsFragment :
|
||||
addPreferencesFromResource(R.xml.pref_network)
|
||||
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = arrayOf(
|
||||
DoHProvider.NONE,
|
||||
DoHProvider.GOOGLE,
|
||||
DoHProvider.CLOUDFLARE,
|
||||
DoHProvider.ADGUARD,
|
||||
).names()
|
||||
entryValues = DoHProvider.entries.names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
bindProxySummary()
|
||||
|
||||
@@ -10,6 +10,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderBackground
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
@@ -25,18 +26,22 @@ class ReaderSettingsFragment :
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_reader)
|
||||
findPreference<ListPreference>(AppSettings.KEY_READER_MODE)?.run {
|
||||
entryValues = ReaderMode.values().names()
|
||||
entryValues = ReaderMode.entries.names()
|
||||
setDefaultValueCompat(ReaderMode.STANDARD.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_READER_BACKGROUND)?.run {
|
||||
entryValues = ReaderBackground.values().names()
|
||||
entryValues = ReaderBackground.entries.names()
|
||||
setDefaultValueCompat(ReaderBackground.DEFAULT.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_READER_ANIMATION)?.run {
|
||||
entryValues = ReaderAnimation.entries.names()
|
||||
setDefaultValueCompat(ReaderAnimation.DEFAULT.name)
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_SWITCHERS)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.gestures_only)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_ZOOM_MODE)?.run {
|
||||
entryValues = ZoomMode.values().names()
|
||||
entryValues = ZoomMode.entries.names()
|
||||
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
|
||||
}
|
||||
updateReaderModeDependency()
|
||||
|
||||
@@ -9,8 +9,10 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
||||
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
|
||||
@@ -18,6 +20,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class NewSourcesViewModel @Inject constructor(
|
||||
private val repository: MangaSourcesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val newSources = SuspendLazy {
|
||||
@@ -26,9 +29,16 @@ class NewSourcesViewModel @Inject constructor(
|
||||
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, enabled, source.getLocaleTitle(), false)
|
||||
SourceConfigItem.SourceItem(
|
||||
source = source,
|
||||
isEnabled = enabled,
|
||||
summary = source.getLocaleTitle(),
|
||||
isDraggable = false,
|
||||
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
@@ -41,6 +42,9 @@ class SourcesManageFragment :
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var reorderHelper: ItemTouchHelper? = null
|
||||
private val viewModel by viewModels<SourcesManageViewModel>()
|
||||
|
||||
@@ -128,9 +132,19 @@ class SourcesManageFragment :
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_no_nsfw -> {
|
||||
settings.isNsfwContentDisabled = !menuItem.isChecked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
||||
return true
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.toEnumSet
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
@@ -54,8 +55,9 @@ class SourcesManageViewModel @Inject constructor(
|
||||
expandedGroups,
|
||||
searchQuery,
|
||||
observeTip(),
|
||||
) { sources, groups, query, tip ->
|
||||
buildList(sources, groups, query, tip)
|
||||
settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled },
|
||||
) { sources, groups, query, tip, noNsfw ->
|
||||
buildList(sources, groups, query, tip, noNsfw)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
@@ -125,6 +127,7 @@ class SourcesManageViewModel @Inject constructor(
|
||||
expanded: Set<String?>,
|
||||
query: String?,
|
||||
withTip: Boolean,
|
||||
isNsfwDisabled: Boolean,
|
||||
): List<SourceConfigItem> {
|
||||
val allSources = repository.allMangaSources
|
||||
val enabledSet = enabledSources.toEnumSet()
|
||||
@@ -138,6 +141,7 @@ class SourcesManageViewModel @Inject constructor(
|
||||
summary = it.getLocaleTitle(),
|
||||
isEnabled = it in enabledSet,
|
||||
isDraggable = false,
|
||||
isAvailable = !isNsfwDisabled || !it.isNsfw(),
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(SourceConfigItem.EmptySearchResult)
|
||||
@@ -163,6 +167,7 @@ class SourcesManageViewModel @Inject constructor(
|
||||
summary = it.getLocaleTitle(),
|
||||
isEnabled = true,
|
||||
isDraggable = true,
|
||||
isAvailable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,6 +189,7 @@ class SourcesManageViewModel @Inject constructor(
|
||||
summary = null,
|
||||
isEnabled = false,
|
||||
isDraggable = false,
|
||||
isAvailable = !isNsfwDisabled || !it.isNsfw(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -210,6 +216,8 @@ class SourcesManageViewModel @Inject constructor(
|
||||
isTipEnabled(TIP_REORDER)
|
||||
}
|
||||
|
||||
private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
||||
|
||||
private class LocaleKeyComparator : Comparator<String?> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user