Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e2cfc878 | ||
|
|
da6db9c1b4 | ||
|
|
88b3e5cf34 | ||
|
|
7347f0ba10 | ||
|
|
4c55682552 | ||
|
|
324031aa2a | ||
|
|
1355c3d75c | ||
|
|
8533168155 | ||
|
|
51f6ec6e55 | ||
|
|
7e3f67c14d | ||
|
|
c51320f033 | ||
|
|
9c50a47abc | ||
|
|
473d273d18 | ||
|
|
f19b628655 | ||
|
|
fa74d4b27a | ||
|
|
cdb6655e37 | ||
|
|
4f19f7ebdf | ||
|
|
bf8838f943 | ||
|
|
1e1e9fabdc | ||
|
|
745972a717 | ||
|
|
6055776329 | ||
|
|
4074791f9a | ||
|
|
b1ab48e912 | ||
|
|
a71e2dd289 | ||
|
|
b8283acd0d |
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 642
|
||||
versionName = '7.0.1'
|
||||
versionCode = 643
|
||||
versionName = '7.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:d218ad5a67') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
@@ -95,9 +95,9 @@ dependencies {
|
||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
@@ -105,7 +105,7 @@ dependencies {
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
|
||||
<activity
|
||||
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/check_for_new_chapters" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -100,6 +100,13 @@
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.READ_MANGA" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||
android:resource="@xml/remote_action" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||
@@ -248,6 +255,9 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
|
||||
android:label="@string/app_update_available" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||
android:label="@string/tracker_debug_info" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -27,13 +27,14 @@ import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
@@ -70,8 +71,9 @@ interface AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
@ApplicationContext context: Context
|
||||
) = NetworkState(context.connectivityManager)
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = NetworkState(context.connectivityManager, settings)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
||||
|
||||
private val isLowRam = application.isLowRamDevice()
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache =
|
||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache =
|
||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||
|
||||
val context: Context?
|
||||
get() = activity ?: fragment?.context
|
||||
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
@@ -56,6 +68,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
@@ -80,13 +98,37 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||
context?.run {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||
context?.run {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = context ?: return
|
||||
val settings = getAppSettings(ctx)
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.ignore_ssl_errors)
|
||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||
ctx.findActivity()?.finishAffinity()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getAppSettings(context: Context): AppSettings {
|
||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
@@ -99,6 +141,9 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ class DoHManager(
|
||||
tryGetByIp("2a10:50c0::2:ff"),
|
||||
),
|
||||
).build()
|
||||
|
||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://0ms.dev/dns-query".toHttpUrl())
|
||||
.resolvePublicAddresses(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||
|
||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
enum class DoHProvider {
|
||||
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
||||
}
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : Interceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
val result = chain.proceed(newRequest)
|
||||
return if (result is SuccessResult) {
|
||||
result
|
||||
} else {
|
||||
logDebug((result as? ErrorResult)?.throwable)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
if (!settings.isImagesProxyEnabled) {
|
||||
return okHttp.newCall(request).await()
|
||||
}
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val newRequest = request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover {
|
||||
logDebug(it)
|
||||
okHttp.doCall(request).also {
|
||||
blacklist.add(sourceUrl.host)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable?) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
@@ -29,6 +31,9 @@ interface NetworkModule {
|
||||
@Binds
|
||||
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
||||
|
||||
@Binds
|
||||
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.network.HttpException
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.Collections
|
||||
|
||||
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newRequest = onInterceptImageRequest(request, url)
|
||||
return when (val result = chain.proceed(newRequest)) {
|
||||
is SuccessResult -> result
|
||||
is ErrorResult -> {
|
||||
logDebug(result.throwable, newRequest.data)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
val newRequest = onInterceptPageRequest(request)
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover { error ->
|
||||
logDebug(error, newRequest.url)
|
||||
okHttp.doCall(request).also {
|
||||
if (error.isBlockedByServer()) {
|
||||
blacklist.add(request.url.host)
|
||||
}
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
|
||||
|
||||
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable, url: Any) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", "${e.message}: $url", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isBlockedByServer(): Boolean {
|
||||
return this is CloudFlareBlockedException
|
||||
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
interface ImageProxyInterceptor : Interceptor {
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.plus
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealImageProxyInterceptor @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : ImageProxyInterceptor {
|
||||
|
||||
private val delegate = settings.observeAsStateFlow(
|
||||
scope = processLifecycleScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_IMAGES_PROXY,
|
||||
valueProducer = { createDelegate() },
|
||||
)
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
|
||||
}
|
||||
|
||||
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
|
||||
}
|
||||
|
||||
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
|
||||
-1 -> null
|
||||
0 -> WsrvNlProxyInterceptor()
|
||||
1 -> ZeroMsProxyInterceptor()
|
||||
else -> error("Unsupported images proxy $proxy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
return request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
return request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
||||
|
||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
|
||||
return request
|
||||
}
|
||||
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.data(newUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
|
||||
return request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import android.net.ConnectivityManager.NetworkCallback
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.isOnline
|
||||
|
||||
class NetworkState(
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
|
||||
private val settings: AppSettings,
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) {
|
||||
|
||||
private val callback = NetworkCallbackImpl()
|
||||
|
||||
@@ -22,6 +24,7 @@ class NetworkState(
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
}
|
||||
@@ -39,7 +42,7 @@ class NetworkState(
|
||||
}
|
||||
|
||||
private fun invalidate() {
|
||||
publishValue(connectivityManager.isOnline())
|
||||
publishValue(connectivityManager.isOnline(settings))
|
||||
}
|
||||
|
||||
private inner class NetworkCallbackImpl : NetworkCallback() {
|
||||
@@ -50,4 +53,27 @@ class NetworkState(
|
||||
|
||||
override fun onUnavailable() = invalidate()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
fun ConnectivityManager.isOnline(settings: AppSettings): Boolean {
|
||||
if (settings.isOfflineCheckDisabled) {
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } ?: false
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
val capabilities = getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.graphics.Canvas
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||
import java.io.OutputStream
|
||||
import android.graphics.Bitmap as AndroidBitmap
|
||||
import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap,
|
||||
) : Bitmap {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||
|
||||
override val height: Int
|
||||
get() = androidBitmap.height
|
||||
|
||||
override val width: Int
|
||||
get() = androidBitmap.width
|
||||
|
||||
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
|
||||
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||
)
|
||||
|
||||
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
@@ -10,15 +11,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
@@ -68,6 +75,27 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||
val image = response.requireBody().byteStream()
|
||||
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||
|
||||
val body = Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
@@ -150,10 +151,6 @@ class FaviconFetcher(
|
||||
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
||||
}
|
||||
|
||||
private fun Response.requireBody(): ResponseBody {
|
||||
return checkNotNull(body) { "response body == null" }
|
||||
}
|
||||
|
||||
private fun Size.toCacheKey() = buildString {
|
||||
append(width.toString())
|
||||
append('x')
|
||||
|
||||
@@ -136,6 +136,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
val isOfflineCheckDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
|
||||
|
||||
var isAllFavouritesVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||
@@ -152,6 +155,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isTrackerNotificationsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||
|
||||
val isTrackerNsfwDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||
|
||||
var notificationSound: Uri
|
||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
@@ -377,14 +383,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isImagesProxyEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
||||
val imagesProxy: Int
|
||||
get() {
|
||||
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
|
||||
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
|
||||
}
|
||||
|
||||
val dnsOverHttps: DoHProvider
|
||||
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
||||
|
||||
val isSSLBypassEnabled: Boolean
|
||||
var isSSLBypassEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
|
||||
|
||||
val proxyType: Proxy.Type
|
||||
get() {
|
||||
@@ -544,8 +554,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
||||
|
||||
const val TRACK_HISTORY = "history"
|
||||
const val TRACK_FAVOURITES = "favourites"
|
||||
|
||||
@@ -557,6 +565,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_COLOR_THEME = "color_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
const val KEY_OFFLINE_DISABLED = "no_offline"
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
||||
@@ -581,6 +590,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
@@ -593,7 +603,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_ZOOM_MODE = "zoom_mode"
|
||||
const val KEY_BACKUP = "backup"
|
||||
const val KEY_RESTORE = "restore"
|
||||
@@ -643,7 +652,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
const val KEY_SOURCES_NEW = "sources_new"
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
@@ -658,7 +666,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_AUTH = "proxy_auth"
|
||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
||||
const val KEY_IMAGES_PROXY = "images_proxy_2"
|
||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||
const val KEY_RELATED_MANGA = "related_manga"
|
||||
@@ -672,7 +680,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||
@@ -680,9 +687,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.prefs
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
@@ -12,6 +11,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
@@ -31,7 +31,11 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
.ifNullOrEmpty { key.defaultValue }
|
||||
.sanitizeHeaderValue()
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue)
|
||||
?.trim()
|
||||
?.takeIf { DomainValidator.isValidDomain(it) }
|
||||
?: key.defaultValue
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
} as T
|
||||
|
||||
@@ -92,8 +92,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
if (supportFragmentManager.popBackStackImmediate()) {
|
||||
return false
|
||||
}
|
||||
dispatchNavigateUp()
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -75,6 +76,9 @@ val Context.activityManager: ActivityManager?
|
||||
val Context.powerManager: PowerManager?
|
||||
get() = getSystemService(POWER_SERVICE) as? PowerManager
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.HttpStatusException
|
||||
@@ -42,6 +41,8 @@ fun Response.ensureSuccess() = apply {
|
||||
}
|
||||
}
|
||||
|
||||
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
||||
|
||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.name(name)
|
||||
c.value(value)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun ConnectivityManager.isOnline(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } ?: false
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
val capabilities = getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.isFileUri
|
||||
|
||||
@@ -27,15 +27,20 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@@ -52,16 +57,21 @@ abstract class FavouritesDao {
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
arrayOf<Any>(categoryId),
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
|
||||
}
|
||||
|
||||
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
||||
|
||||
@@ -38,8 +38,8 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order)
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(order, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
@@ -48,14 +48,14 @@ class FavouritesRepository @Inject constructor(
|
||||
return entities.toMangaList()
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order)
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, limit)
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
|
||||
return observeOrder(categoryId)
|
||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
||||
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
|
||||
}
|
||||
|
||||
fun observeMangaCount(): Flow<Int> {
|
||||
@@ -63,12 +63,6 @@ class FavouritesRepository @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun getCategories(): List<FavouriteCategory> {
|
||||
return db.getFavouriteCategoriesDao().findAll().map {
|
||||
it.toFavouriteCategory()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||
return db.getFavouriteCategoriesDao().observeAll().mapItems {
|
||||
it.toFavouriteCategory()
|
||||
|
||||
@@ -33,7 +33,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
||||
binding.recyclerView.isVP2BugWorkaroundEnabled = true
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onFilterClick(view: View?) {
|
||||
val menu = PopupMenu(view?.context ?: return, view)
|
||||
|
||||
@@ -32,8 +32,11 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
@@ -46,6 +49,8 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
private val refreshTrigger = MutableStateFlow(Any())
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
|
||||
@@ -61,13 +66,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
override val content = combine(
|
||||
if (categoryId == NO_ID) {
|
||||
sortOrder.filterNotNull().flatMapLatest {
|
||||
repository.observeAll(it)
|
||||
}
|
||||
} else {
|
||||
repository.observeAll(categoryId)
|
||||
},
|
||||
observeFavorites(),
|
||||
listMode,
|
||||
refreshTrigger,
|
||||
) { list, mode, _ ->
|
||||
@@ -85,7 +84,10 @@ class FavouritesListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> list.toUi(mode, listExtraProvider)
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
list.toUi(mode, listExtraProvider)
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
@@ -126,4 +128,19 @@ class FavouritesListViewModel @Inject constructor(
|
||||
repository.setCategoryOrder(categoryId, order)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeFavorites() = if (categoryId == NO_ID) {
|
||||
combine(sortOrder.filterNotNull(), limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAll(it.first, it.second) }
|
||||
} else {
|
||||
limit.flatMapLatest {
|
||||
repository.observeAll(categoryId, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
|
||||
@@ -28,8 +27,7 @@ abstract class HistoryDao {
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||
|
||||
// TODO pagination
|
||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
|
||||
val orderBy = when (order) {
|
||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||
@@ -43,13 +41,18 @@ abstract class HistoryDao {
|
||||
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
val query = buildString {
|
||||
append(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
|
||||
)
|
||||
append(orderBy)
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||
}
|
||||
|
||||
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
|
||||
|
||||
@@ -74,8 +74,8 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order).mapItems {
|
||||
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
|
||||
return db.getHistoryDao().observeAll(order, limit).mapItems {
|
||||
MangaWithHistory(
|
||||
it.manga.toManga(it.tags.toMangaTags()),
|
||||
it.history.toMangaHistory(),
|
||||
|
||||
@@ -32,7 +32,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() = Unit
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
startActivity(NetworkManageIntent())
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.ui
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -43,8 +44,11 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryListViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
@@ -62,8 +66,11 @@ class HistoryListViewModel @Inject constructor(
|
||||
valueProducer = { historySortOrder },
|
||||
)
|
||||
|
||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode)
|
||||
override val listMode = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||
valueProducer = { historyListMode },
|
||||
)
|
||||
|
||||
private val isGroupingEnabled = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_HISTORY_GROUPING,
|
||||
@@ -72,6 +79,9 @@ class HistoryListViewModel @Inject constructor(
|
||||
g && s.isGroupingSupported()
|
||||
}
|
||||
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isReady = AtomicBoolean(false)
|
||||
|
||||
val isStatsEnabled = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_STATS_ENABLED,
|
||||
@@ -79,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
||||
observeHistory(),
|
||||
isGroupingEnabled,
|
||||
listMode,
|
||||
networkState,
|
||||
@@ -95,7 +105,10 @@ class HistoryListViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
else -> mapList(list, grouped, mode, online, incognito)
|
||||
else -> {
|
||||
isReady.set(true)
|
||||
mapList(list, grouped, mode, online, incognito)
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
loadingCounter.increment()
|
||||
@@ -138,6 +151,15 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMoreItems() {
|
||||
if (isReady.compareAndSet(true, false)) {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
|
||||
|
||||
private suspend fun mapList(
|
||||
list: List<MangaWithHistory>,
|
||||
grouped: Boolean,
|
||||
|
||||
@@ -7,11 +7,12 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ErrorResult
|
||||
@@ -20,17 +21,26 @@ import coil.request.SuccessResult
|
||||
import coil.target.ViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
|
||||
@@ -39,27 +49,45 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var errorBinding: ItemErrorStateBinding? = null
|
||||
private val viewModel: ImageViewModel by viewModels()
|
||||
private lateinit var menuMediator: PopupMenuMediator
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||
viewBinding.buttonBack.setOnClickListener(this)
|
||||
loadImage(intent.data)
|
||||
viewBinding.buttonMenu.setOnClickListener(this)
|
||||
val imageUrl = requireNotNull(intent.data)
|
||||
|
||||
val menuProvider = ImageMenuProvider(
|
||||
activity = this,
|
||||
snackbarHost = viewBinding.root,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
menuMediator = PopupMenuMediator(menuProvider)
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
|
||||
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
|
||||
loadImage(imageUrl)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
with(viewBinding.buttonBack) {
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + marginBottom
|
||||
leftMargin = insets.left + marginBottom
|
||||
rightMargin = insets.right + marginBottom
|
||||
}
|
||||
viewBinding.buttonBack.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
}
|
||||
viewBinding.buttonMenu.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top + bottomMargin
|
||||
leftMargin = insets.left + bottomMargin
|
||||
rightMargin = insets.right + bottomMargin
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_back -> dispatchNavigateUp()
|
||||
R.id.button_menu -> menuMediator.onLongClick(v)
|
||||
else -> loadImage(intent.data)
|
||||
}
|
||||
}
|
||||
@@ -92,11 +120,34 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.lifecycle(this)
|
||||
.listener(this)
|
||||
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.source(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
||||
.target(SsivTarget(viewBinding.ssiv))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private fun onImageSaved(uri: Uri) {
|
||||
Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.share) {
|
||||
ShareHelper(this).shareImage(uri)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonMenu
|
||||
button.isClickable = !isLoading
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||
}
|
||||
}
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : ViewTarget<SubsamplingScaleImageView> {
|
||||
@@ -124,7 +175,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
||||
return Intent(context, ImageActivity::class.java)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.local.data.isZipUri
|
||||
|
||||
class ImageMenuProvider(
|
||||
private val activity: ComponentActivity,
|
||||
private val snackbarHost: View,
|
||||
private val viewModel: ImageViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
private val permissionLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
private val saveLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("image/png"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
viewModel.saveImage(uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_image, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_save -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
saveImage()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun saveImage() {
|
||||
val name = activity.intent.data?.let {
|
||||
if (it.isZipUri()) {
|
||||
it.fragment
|
||||
} else {
|
||||
it.lastPathSegment
|
||||
}?.substringBeforeLast('.')?.plus(".png")
|
||||
}
|
||||
if (name == null || !saveLauncher.tryLaunch(name)) {
|
||||
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.image.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ImageViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val coil: ImageLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onImageSaved = MutableEventFlow<Uri>()
|
||||
|
||||
fun saveImage(destination: Uri) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.memoryCachePolicy(CachePolicy.READ_ONLY)
|
||||
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
|
||||
.build()
|
||||
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(destination)?.use { output ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
|
||||
} ?: error("Cannot open output stream")
|
||||
}
|
||||
onImageSaved.call(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.use
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -36,6 +36,17 @@ class ReaderControlDelegate(
|
||||
}
|
||||
|
||||
fun onKeyDown(keyCode: Int): Boolean = when (keyCode) {
|
||||
|
||||
KeyEvent.KEYCODE_R -> {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_L -> {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.Preference
|
||||
@@ -21,9 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isScrolledToTop
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
@@ -39,17 +36,18 @@ import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
class SettingsActivity :
|
||||
BaseActivity<ActivitySettingsBinding>(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
AppBarOwner,
|
||||
FragmentManager.OnBackStackChangedListener {
|
||||
AppBarOwner {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
private val isMasterDetails
|
||||
get() = viewBinding.containerMaster != null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val isMasterDetails = viewBinding.containerMaster != null
|
||||
val fm = supportFragmentManager
|
||||
val currentFragment = fm.findFragmentById(R.id.container)
|
||||
if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) {
|
||||
@@ -63,21 +61,6 @@ class SettingsActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence?, color: Int) {
|
||||
super.onTitleChanged(title, color)
|
||||
viewBinding.collapsingToolbarLayout?.title = title
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
supportFragmentManager.addOnBackStackChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
supportFragmentManager.removeOnBackStackChangedListener(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
@@ -110,14 +93,6 @@ class SettingsActivity :
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackStackChanged() {
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
|
||||
val recyclerView = fragment.recyclerView
|
||||
recyclerView.post {
|
||||
viewBinding.appbar.setExpanded(recyclerView.isScrolledToTop, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference,
|
||||
@@ -147,19 +122,17 @@ class SettingsActivity :
|
||||
|
||||
fun openFragment(fragment: Fragment, isFromRoot: Boolean) {
|
||||
val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null
|
||||
val isMasterDetail = viewBinding.containerMaster != null
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN)
|
||||
if (!isMasterDetail || (hasFragment && !isFromRoot)) {
|
||||
if (!isMasterDetails || (hasFragment && !isFromRoot)) {
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDefaultFragment() {
|
||||
val hasMaster = viewBinding.containerMaster != null
|
||||
val fragment = when (intent?.action) {
|
||||
ACTION_READER -> ReaderSettingsFragment()
|
||||
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||
@@ -181,7 +154,7 @@ class SettingsActivity :
|
||||
}
|
||||
|
||||
else -> null
|
||||
} ?: if (hasMaster) AppearanceSettingsFragment() else RootSettingsFragment()
|
||||
} ?: if (isMasterDetails) AppearanceSettingsFragment() else RootSettingsFragment()
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -40,6 +41,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
|
||||
if (!isEnabled) isChecked = true
|
||||
}
|
||||
if (!settings.isTrackerEnabled) {
|
||||
findPreference<Preference>(AppSettings.KEY_TRACKER_DEBUG)?.run {
|
||||
isEnabled = false
|
||||
setSummary(R.string.check_for_new_chapters_disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -67,6 +74,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACKER_DEBUG -> {
|
||||
startActivity(Intent(preference.context, TrackerDebugActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,20 +11,24 @@ class DomainValidator : EditTextValidator() {
|
||||
if (trimmed.isEmpty()) {
|
||||
return ValidationResult.Success
|
||||
}
|
||||
return if (!checkCharacters(trimmed)) {
|
||||
return if (!isValidDomain(trimmed)) {
|
||||
ValidationResult.Failed(context.getString(R.string.invalid_domain_message))
|
||||
} else {
|
||||
ValidationResult.Success
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCharacters(value: String): Boolean = runCatching {
|
||||
val parts = value.split(':')
|
||||
require(parts.size <= 2)
|
||||
val urlBuilder = HttpUrl.Builder()
|
||||
urlBuilder.host(parts.first())
|
||||
if (parts.size == 2) {
|
||||
urlBuilder.port(parts[1].toInt())
|
||||
}
|
||||
}.isSuccess
|
||||
companion object {
|
||||
|
||||
fun isValidDomain(value: String): Boolean = runCatching {
|
||||
require(value.isNotEmpty())
|
||||
val parts = value.split(':')
|
||||
require(parts.size <= 2)
|
||||
val urlBuilder = HttpUrl.Builder()
|
||||
urlBuilder.host(parts.first())
|
||||
if (parts.size == 2) {
|
||||
urlBuilder.port(parts[1].toInt())
|
||||
}
|
||||
}.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnList
|
||||
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
||||
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = tracksAdapter
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
}
|
||||
@@ -49,6 +49,9 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
if (newChapters.isEmpty() || !applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return null
|
||||
}
|
||||
if (manga.isNsfw && settings.isTrackerNsfwDisabled) {
|
||||
return null
|
||||
}
|
||||
val id = manga.url.hashCode()
|
||||
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
val summary = applicationContext.resources.getQuantityString(
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
android:scaleType="center"
|
||||
app:srcCompat="?homeAsUpIndicator" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_menu"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="end"
|
||||
android:layout_margin="@dimen/screen_padding"
|
||||
android:background="@drawable/bg_circle_button"
|
||||
android:contentDescription="@string/back"
|
||||
android:elevation="@dimen/m3_sys_elevation_level1"
|
||||
android:scaleType="center"
|
||||
app:srcCompat="@drawable/abc_ic_menu_overflow_material" />
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -11,21 +11,11 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:id="@+id/collapsingToolbarLayout"
|
||||
style="?collapsingToolbarLayoutMediumStyle"
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?collapsingToolbarLayoutMediumSize"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
||||
app:toolbarId="@id/toolbar">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_collapseMode="pin" />
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_collapseMode="pin" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
|
||||
android:layout_marginBottom="16dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
9
app/src/main/res/menu/opt_image.xml
Normal file
9
app/src/main/res/menu/opt_image.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:title="@string/save" />
|
||||
|
||||
</menu>
|
||||
@@ -301,7 +301,7 @@
|
||||
<string name="categories_delete_confirm">¿Estás seguro de que quieres eliminar las categorías favoritas seleccionadas\?
|
||||
\nTodos los mangas en ella se perderán y esto no se puede deshacer.</string>
|
||||
<string name="explore">Explorar</string>
|
||||
<string name="memory_usage_pattern">%s - %s</string>
|
||||
<string name="memory_usage_pattern">%1$s - %2$s</string>
|
||||
<string name="exit_confirmation_summary">Pulse dos veces «Atrás» para salir de la aplicación</string>
|
||||
<string name="exit_confirmation">Confirmación de salida</string>
|
||||
<string name="pages_cache">Caché de páginas</string>
|
||||
@@ -634,4 +634,7 @@
|
||||
<string name="blocked_by_server_message">Estás bloqueado por el servidor. Intente utilizar una conexión de red diferente (VPN, Proxy, etc.)</string>
|
||||
<string name="disable">Desactivar</string>
|
||||
<string name="sources_disabled">Fuentes deshabilitadas</string>
|
||||
<string name="disable_connectivity_check">Desactivar el control de conectividad</string>
|
||||
<string name="ignore_ssl_errors_summary">Puede desactivar la verificación de certificados SSL en caso de que tenga problemas relacionados con SSL al acceder a recursos de red. Esto puede afectar a su seguridad. Es necesario reiniciar la aplicación después de cambiar esta configuración.</string>
|
||||
<string name="disable_connectivity_check_summary">Omitir la comprobación de la conectividad en caso de que tenga problemas con ella (por ejemplo, si pasa al modo sin conexión aunque la red esté conectada)</string>
|
||||
</resources>
|
||||
@@ -634,4 +634,7 @@
|
||||
<string name="blocked_by_server_message">Hinarangan ka ng server. Subukang gumamit ng ibang koneksyon sa network (VPN, Proxy, atbp.)</string>
|
||||
<string name="disable">Di paganahin</string>
|
||||
<string name="sources_disabled">Di pinagana ang mga source</string>
|
||||
<string name="disable_connectivity_check">Di paganahin ang pagtingin sa koneksyon</string>
|
||||
<string name="disable_connectivity_check_summary">Laktawan ang pagsuri sa koneksyon kung sakaling mayroon kang isyu rito (hal. pagpunta sa offline mode kahit na nakakonekta sa network)</string>
|
||||
<string name="ignore_ssl_errors_summary">Maaaring di paganahin ang pag-verify ng mga SSL certificate kung sakaling makaharap ka ng mga isyu na nauugnay sa SSL kapag nag-a-access ng mga network resource. Ito ay makaapekto sa iyong seguridad. Kinakailangang mag-restart ang aplikasyon pagkatapos baguhin ang setting na ito.</string>
|
||||
</resources>
|
||||
@@ -634,4 +634,7 @@
|
||||
<string name="blocked_by_server_message">आपको सर्वर द्वारा अवरुद्ध कर दिया गया है। किसी भिन्न नेटवर्क कनेक्शन (VPN, प्रॉक्सी, आदि) का उपयोग करने का प्रयास करें</string>
|
||||
<string name="disable">अक्षम करें</string>
|
||||
<string name="sources_disabled">स्रोत अक्षम</string>
|
||||
<string name="disable_connectivity_check">कनेक्टिविटी जांच अक्षम करें</string>
|
||||
<string name="ignore_ssl_errors_summary">यदि नेटवर्क संसाधनों तक पहुँचने के दौरान आपको SSL से संबंधित समस्याओं का सामना करना पड़ता है तो आप SSL प्रमाणपत्र सत्यापन को अक्षम कर सकते हैं। इससे आपकी सुरक्षा प्रभावित हो सकती है। इस सेटिंग को बदलने के बाद एप्लिकेशन को पुनरारंभ करना आवश्यक है।</string>
|
||||
<string name="disable_connectivity_check_summary">यदि आपको कनेक्टिविटी से जुड़ी कोई समस्या है तो कनेक्टिविटी जांच को छोड़ दें (उदाहरण के लिए नेटवर्क कनेक्ट होने के बावजूद ऑफ़लाइन मोड में जाना)</string>
|
||||
</resources>
|
||||
@@ -307,7 +307,7 @@
|
||||
<string name="other_cache">Altra cache</string>
|
||||
<string name="storage_usage">Utilizzo dello spazio di archiviazione</string>
|
||||
<string name="available">Disponibile</string>
|
||||
<string name="memory_usage_pattern">%s - %s</string>
|
||||
<string name="memory_usage_pattern">%1$s - %2$s</string>
|
||||
<string name="removed_from_favourites">Rimosso dai preferiti</string>
|
||||
<string name="options">Opzioni</string>
|
||||
<string name="no_chapters">Nessun capitolo</string>
|
||||
@@ -318,7 +318,7 @@
|
||||
<string name="importing_manga">Importazione di manga</string>
|
||||
<string name="import_will_start_soon">L\'importazione inizierà presto</string>
|
||||
<string name="feed">Flusso</string>
|
||||
<string name="reader_control_ltr_summary">Toccando il bordo destro o premendo il tasto destro si passa sempre alla pagina successiva</string>
|
||||
<string name="reader_control_ltr_summary">Toccando il bordo destro o premendo il tasto destro si passa sempre alla pagina successiva.</string>
|
||||
<string name="contrast">Contrasto</string>
|
||||
<string name="reset">Ripristina</string>
|
||||
<string name="reader_slider">Mostra il cursore di cambio pagina</string>
|
||||
@@ -538,4 +538,59 @@
|
||||
<string name="volume_">Volume %d</string>
|
||||
<string name="category_hidden_done">Questa categoria è stata nascosta dalla schermata principale ed è accessibile tramite Menù → Gestisci categorie</string>
|
||||
<string name="remaining_time_pattern">%1$s %2$s</string>
|
||||
<string name="last_used">Ultimo utilizzo</string>
|
||||
<string name="incognito_mode_hint">I tuoi progressi di lettura non saranno salvati</string>
|
||||
<string name="vertical">Verticale</string>
|
||||
<string name="last_read">Ultima lettura</string>
|
||||
<string name="reading_time_estimation_summary">Il tempo di lettura stimato potrebbe essere inaccurato</string>
|
||||
<string name="reading_time_estimation">Mostra il tempo di lettura stimato</string>
|
||||
<string name="email_password_enter_hint">Inserisci la tua email e password per continuare</string>
|
||||
<string name="show_menu">Mostra menu</string>
|
||||
<string name="switch_pages_volume_buttons_summary">Usa i pulsanti del volume per cambiare pagine</string>
|
||||
<string name="tap_action">Azioni tap</string>
|
||||
<string name="long_tap_action">Azioni tap prolungato</string>
|
||||
<string name="none">Nessuno</string>
|
||||
<string name="fullscreen_mode">Modalità schermo interno</string>
|
||||
<string name="chapters_grid_view">Vista griglia</string>
|
||||
<string name="prev_chapter">Capitolo precedente</string>
|
||||
<string name="reader_actions_summary">Configura le azioni per le aree di schermo cliccabili</string>
|
||||
<string name="suggestions_unavailable_text">La funzione di suggerimento è disabilitata</string>
|
||||
<string name="show_labels_in_navbar">Mostra le etichette nella barra di navigazione</string>
|
||||
<string name="delete_read_chapters_summary">Rimuovi i capitoli già letti dalla memoria locale per liberare spazio</string>
|
||||
<string name="delete_read_chapters_prompt">Questo eliminerà in modo permanente tutti i capitoli segnati come già letti dalla memoria locale. Puoi scaricarli di nuovo, ma i capitoli importati potrebbero essere persi per sempre</string>
|
||||
<string name="order_oldest">Meno recenti</string>
|
||||
<string name="long_ago_read">Letto molto tempo fa</string>
|
||||
<string name="unsupported_source">Questa fonte manga non è supportata</string>
|
||||
<string name="hours_short">%d ore</string>
|
||||
<string name="minutes_short">%d m</string>
|
||||
<string name="show_updated">Mostra aggiornato</string>
|
||||
<string name="toggle_ui">Mostra/nascondi UI</string>
|
||||
<string name="next_chapter">Prossimo capitolo</string>
|
||||
<string name="prev_page">Pagina precedente</string>
|
||||
<string name="next_page">Prossima pagina</string>
|
||||
<string name="reader_actions">Azioni del lettore</string>
|
||||
<string name="switch_pages_volume_buttons">Abilita pulsanti del volume</string>
|
||||
<string name="reader_fullscreen_summary">Nascondi le barre di stato e di notifica</string>
|
||||
<string name="check_for_new_chapters_disabled">Il controllo di nuovi capitoli è disabilitato</string>
|
||||
<string name="delete_read_chapters_auto">Elimina i capitoli già letti automaticamente</string>
|
||||
<string name="runs_on_app_start">Esegui quando l\'applicazione viene avviata</string>
|
||||
<string name="split_by_translations">Dividi per traduzioni</string>
|
||||
<string name="split_by_translations_summary">Mostra separatamente i capitoli con diverse traduzioni, invece che in un\'unica lista</string>
|
||||
<string name="unread">Non letto</string>
|
||||
<string name="show_pages_thumbs">Mostra le anteprime delle pagine</string>
|
||||
<string name="show_pages_thumbs_summary">Abilita la tab \"Pagine\" nella schermata di dettaglio</string>
|
||||
<string name="unsupported_backup_message">Si prega di selezione un file backup di Kotatsu corretto</string>
|
||||
<string name="hours_minutes_short">%1$d h %2$d m</string>
|
||||
<string name="fix">Aggiustamenti</string>
|
||||
<string name="missing_storage_permission">Non sono presenti i permessi per accedere al manga nella memoria esterna</string>
|
||||
<string name="webtoon_gaps">Gap in modalità webtoon</string>
|
||||
<string name="webtoon_gaps_summary">Mostra gap verticali tra le pagine in modalità webotoon</string>
|
||||
<string name="config_reset_confirm">Ripristinare le impostazioni ai valori predefiniti? Questo procedimento è irreversibile.</string>
|
||||
<string name="use_two_pages_landscape">Usa il layout a due pagine con l\'orientamento orizzontale (beta)</string>
|
||||
<string name="error_no_data_received">Non è stato ricevuto nessun dato dal server</string>
|
||||
<string name="enable_source">Attiva fonte</string>
|
||||
<string name="less_frequently">Meno frequente</string>
|
||||
<string name="more_frequently">Più frequente</string>
|
||||
<string name="frequency_of_check">Frequenza di controllo</string>
|
||||
<string name="new_chapters_pattern">%1$s: %2$d</string>
|
||||
</resources>
|
||||
@@ -236,7 +236,7 @@
|
||||
<string name="invalid_domain_message">Nieważna domena</string>
|
||||
<string name="reorder">Zmień kolejność</string>
|
||||
<string name="exit_confirmation">Potwierdzenie wyjścia</string>
|
||||
<string name="memory_usage_pattern">%s - %s</string>
|
||||
<string name="memory_usage_pattern">%1$s - %2$s</string>
|
||||
<string name="reader_info_pattern">Rozdz. %1$d/%2$d Str. %3$d/%4$d</string>
|
||||
<string name="network_unavailable_hint">Włącz Wi-Fi lub sieć komórkową, aby czytać mangę online</string>
|
||||
<string name="_import">Importuj</string>
|
||||
@@ -632,4 +632,9 @@
|
||||
<string name="pin_navigation_ui_summary">Nie ukrywaj paska nawigacji i paska wyszukiwania podczas przewijania</string>
|
||||
<string name="search_suggestions">Sugestie wyszukiwania</string>
|
||||
<string name="blocked_by_server_message">Jesteś zablokowany przez serwer. Spróbuj użyć innego połączenia sieciowego (VPN, proxy itp.)</string>
|
||||
<string name="sources_disabled">Źródła wyłączone</string>
|
||||
<string name="disable">Wyłączone</string>
|
||||
<string name="disable_connectivity_check">Wyłącz sprawdzanie łączności</string>
|
||||
<string name="ignore_ssl_errors_summary">Możesz wyłączyć weryfikację certyfikatów SSL w przypadku wystąpienia problemów związanych z SSL podczas uzyskiwania dostępu do zasobów sieciowych. Może to mieć wpływ na Twoje bezpieczeństwo. Po zmianie tego ustawienia wymagane jest ponowne uruchomienie aplikacji.</string>
|
||||
<string name="disable_connectivity_check_summary">Pomiń sprawdzanie łączności w przypadku problemów z nią (np. przejście do trybu offline, mimo że sieć jest podłączona)</string>
|
||||
</resources>
|
||||
@@ -308,7 +308,7 @@
|
||||
<string name="reader_info_pattern">Cap. %1$d/%2$d Pág. %3$d/%4$d</string>
|
||||
<string name="reader_info_bar">Mostrar barra de informações no leitor</string>
|
||||
<string name="folder_with_images">Pasta com imagens</string>
|
||||
<string name="memory_usage_pattern">%s - %s</string>
|
||||
<string name="memory_usage_pattern">%1$s - %2$s</string>
|
||||
<string name="comics_archive">Arquivo de quadrinhos</string>
|
||||
<string name="importing_manga">Importando mangá(s)</string>
|
||||
<string name="import_completed">Importação completa</string>
|
||||
@@ -619,4 +619,22 @@
|
||||
<string name="delete_read_chapters_prompt">Isso irá apagar permanentemente todos os capítulos marcados como lidos do armazenamento local. Você pode baixá-los novamente mais tarde, mas os capítulos importados podem ser perdidos para sempre</string>
|
||||
<string name="last_used">Usado pela última vez</string>
|
||||
<string name="show_updated">Mostrar atualização</string>
|
||||
<string name="disable_connectivity_check">Desativar a verificação de conectividade</string>
|
||||
<string name="disable_connectivity_check_summary">Ignore a verificação de conectividade caso tenha problemas com ela (por exemplo, entrar no modo off-line mesmo que a rede esteja conectada)</string>
|
||||
<string name="webtoon_gaps">Lacunas no modo webtoon</string>
|
||||
<string name="webtoon_gaps_summary">Mostrar espaços verticais entre as páginas no modo webtoon</string>
|
||||
<string name="authors">Autores</string>
|
||||
<string name="ignore_ssl_errors_summary">Você pode desativar a verificação de certificados SSL caso tenha problemas relacionados a SSL ao acessar recursos de rede. Isso pode afetar sua segurança. É necessário reiniciar o aplicativo após alterar essa configuração.</string>
|
||||
<string name="search_suggestions">Sugestões de Pesquisa</string>
|
||||
<string name="recent_queries">Consultas recentes</string>
|
||||
<string name="suggested_queries">Consultas sugeridas</string>
|
||||
<string name="disable">Desativar</string>
|
||||
<string name="sources_disabled">Fontes desativadas</string>
|
||||
<string name="blocked_by_server_message">Você está bloqueado pelo servidor. Tente usar uma conexão de internet diferente (VPN, Proxy, etc.)</string>
|
||||
<string name="less_frequently">Com menos frequência</string>
|
||||
<string name="more_frequently">Com mais frequência</string>
|
||||
<string name="frequency_of_check">Frequência de verificação</string>
|
||||
<string name="new_chapters_pattern">%1$s: %2$d</string>
|
||||
<string name="pin_navigation_ui">Fixar interface de navegação</string>
|
||||
<string name="pin_navigation_ui_summary">Não esconder barra de navegação e visualização de pesquisa ao rolar</string>
|
||||
</resources>
|
||||
@@ -628,7 +628,6 @@
|
||||
<string name="less_frequently">Menos frequência</string>
|
||||
<string name="more_frequently">Mais frequência</string>
|
||||
<string name="frequency_of_check">Frequência de verificação</string>
|
||||
<string name="new_chapters_pattern">novo padrão de capítulos</string>
|
||||
<string name="pin_navigation_ui">Fixar interface de navegação</string>
|
||||
<string name="pin_navigation_ui_summary">Ao rolar, não esconda a barra de navegação e a visualização de busca</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -634,4 +634,7 @@
|
||||
<string name="blocked_by_server_message">Sunucu tarafından engellendiniz. Farklı bir ağ bağlantısı kullanmayı deneyin (VPN, vekil sunucu, vb.)</string>
|
||||
<string name="disable">Devre dışı bırak</string>
|
||||
<string name="sources_disabled">Kaynaklar devre dışı bırakıldı</string>
|
||||
<string name="disable_connectivity_check">Bağlantı denetimini devre dışı bırak</string>
|
||||
<string name="ignore_ssl_errors_summary">Ağ kaynaklarına erişirken SSL ile ilgili bir sorunla karşılaşmanız durumunda SSL sertifikaları doğrulamasını devre dışı bırakabilirsiniz. Bu durum güvenliğinizi etkileyebilir. Bu ayarı değiştirdikten sonra uygulamanın yeniden başlatılması gerekir.</string>
|
||||
<string name="disable_connectivity_check_summary">Sorun yaşamanız durumunda bağlantı denetimini atlayın (örneğin, ağ bağlı olmasına rağmen çevrim dışı moda geçiş)</string>
|
||||
</resources>
|
||||
@@ -634,4 +634,7 @@
|
||||
<string name="blocked_by_server_message">你的访问已被服务器拦截,请尝试使用不同的网络连接访问(如VPN代理等)</string>
|
||||
<string name="disable">关闭</string>
|
||||
<string name="sources_disabled">图源已关闭</string>
|
||||
<string name="disable_connectivity_check">关闭连接连通性检查</string>
|
||||
<string name="disable_connectivity_check_summary">若连通性检查存在问题可打开此选项(例:即使连接了网络但依旧提示网络断开)</string>
|
||||
<string name="ignore_ssl_errors_summary">若在连接到在线图源时SSL证书出现问题,可关闭SSL证书认证,关闭后对安全性有所影响,需要重启应用来更改设置。</string>
|
||||
</resources>
|
||||
@@ -35,6 +35,12 @@
|
||||
<item>Google</item>
|
||||
<item>CloudFlare</item>
|
||||
<item>AdGuard</item>
|
||||
<item>0ms</item>
|
||||
</string-array>
|
||||
<string-array name="image_proxies" translatable="false">
|
||||
<item>@string/none</item>
|
||||
<item>wsrv.nl</item>
|
||||
<item>0ms.dev</item>
|
||||
</string-array>
|
||||
<string-array name="reader_modes" translatable="false">
|
||||
<item>@string/standard</item>
|
||||
|
||||
@@ -40,8 +40,14 @@
|
||||
<item>2</item>
|
||||
<item>0</item>
|
||||
</string-array>
|
||||
<string-array name="values_image_proxies" translatable="false">
|
||||
<item>-1</item>
|
||||
<item>0</item>
|
||||
<item>1</item>
|
||||
</string-array>
|
||||
<string-array name="sync_host_list" translatable="false">
|
||||
<item>@string/sync_host_default</item>
|
||||
<item>moe.shirizu.org</item>
|
||||
<item>86.57.183.214:8081</item>
|
||||
</string-array>
|
||||
<string-array name="values_proxy_types" translatable="false">
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
<string name="various_languages">Various languages</string>
|
||||
<string name="search_chapters">Find chapter</string>
|
||||
<string name="chapters_empty">No chapters in this manga</string>
|
||||
<string name="percent_string_pattern">%1$s%%</string>
|
||||
<string name="percent_string_pattern" translatable="false">%1$s%%</string>
|
||||
<string name="appearance">Appearance</string>
|
||||
<string name="suggestions_updating">Suggestions updating</string>
|
||||
<string name="suggestions_excluded_genres">Exclude genres</string>
|
||||
@@ -301,7 +301,7 @@
|
||||
<string name="other_cache">Other cache</string>
|
||||
<string name="storage_usage">Storage usage</string>
|
||||
<string name="available">Available</string>
|
||||
<string name="memory_usage_pattern">%1$s - %2$s</string>
|
||||
<string name="memory_usage_pattern" translatable="false">%1$s - %2$s</string>
|
||||
<string name="removed_from_favourites">Removed from favourites</string>
|
||||
<string name="options">Options</string>
|
||||
<string name="not_found_404">Content not found or removed</string>
|
||||
@@ -498,7 +498,7 @@
|
||||
<string name="content_type_hentai">Hentai</string>
|
||||
<string name="content_type_comics">Comics</string>
|
||||
<string name="content_type_other">Other</string>
|
||||
<string name="source_summary_pattern">%1$s, %2$s</string>
|
||||
<string name="source_summary_pattern" translatable="false">%1$s, %2$s</string>
|
||||
<string name="sources_catalog">Sources catalog</string>
|
||||
<string name="source_enabled">Source enabled</string>
|
||||
<string name="no_manga_sources_catalog_text">There are no sources available in this section, or all of it might have been already added.\nStay tuned</string>
|
||||
@@ -541,7 +541,7 @@
|
||||
<string name="mark_as_completed">Mark as completed</string>
|
||||
<string name="mark_as_completed_prompt">Mark selected manga as completely read?\n\nWarning: current reading progress will be lost.</string>
|
||||
<string name="category_hidden_done">This category was hidden from the main screen and is accessible through Menu → Manage categories</string>
|
||||
<string name="remaining_time_pattern">%1$s %2$s</string>
|
||||
<string name="remaining_time_pattern" translatable="false">%1$s %2$s</string>
|
||||
<string name="volume_">Volume %d</string>
|
||||
<string name="volume_unknown">Unknown volume</string>
|
||||
<string name="incognito_mode_hint">Your reading progress will not be saved</string>
|
||||
@@ -633,7 +633,7 @@
|
||||
<string name="less_frequently">Less frequently</string>
|
||||
<string name="more_frequently">More frequently</string>
|
||||
<string name="frequency_of_check">Frequency of check</string>
|
||||
<string name="new_chapters_pattern">%1$s: %2$d</string>
|
||||
<string name="new_chapters_pattern" translatable="false">%1$s: %2$d</string>
|
||||
<string name="pin_navigation_ui">Pin navigation UI</string>
|
||||
<string name="pin_navigation_ui_summary">Do not hide navigation bar and search view on scroll</string>
|
||||
<string name="search_suggestions">Search suggestions</string>
|
||||
@@ -643,4 +643,11 @@
|
||||
<string name="blocked_by_server_message">You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.)</string>
|
||||
<string name="disable">Disable</string>
|
||||
<string name="sources_disabled">Sources disabled</string>
|
||||
<string name="disable_connectivity_check">Disable connectivity check</string>
|
||||
<string name="ignore_ssl_errors_summary">You can disable SSL certificates verification in case you face an SSL-related issues when accessing network resources. This may affect your security. Application restarting is required after changing this setting.</string>
|
||||
<string name="disable_connectivity_check_summary">Skip the connectivity check in case you have issues with it (e.g. going offline mode even though the network is connected)</string>
|
||||
<string name="disable_nsfw_notifications">Disable NSFW notifications</string>
|
||||
<string name="disable_nsfw_notifications_summary">Do not show notifications about NSFW manga updates</string>
|
||||
<string name="tracker_debug_info">Checking for new chapters log</string>
|
||||
<string name="tracker_debug_info_summary">Debug information about background checks for new chapters</string>
|
||||
</resources>
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
android:key="logs_share"
|
||||
android:title="@string/share_logs" />
|
||||
|
||||
<Preference
|
||||
android:key="tracker_debug"
|
||||
android:persistent="false"
|
||||
android:summary="@string/tracker_debug_info_summary"
|
||||
android:title="@string/tracker_debug_info" />
|
||||
|
||||
<Preference
|
||||
android:key="about_app_translation"
|
||||
android:summary="@string/about_app_translation_summary"
|
||||
|
||||
@@ -33,11 +33,13 @@
|
||||
android:title="@string/dns_over_https"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="images_proxy"
|
||||
android:summary="@string/images_procy_description"
|
||||
android:title="@string/images_proxy_title" />
|
||||
<ListPreference
|
||||
android:defaultValue="-1"
|
||||
android:entries="@array/image_proxies"
|
||||
android:entryValues="@array/values_image_proxies"
|
||||
android:key="images_proxy_2"
|
||||
android:title="@string/images_proxy_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
@@ -47,6 +49,12 @@
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="ssl_bypass"
|
||||
android:summary="@string/ignore_ssl_errors_summary"
|
||||
android:title="@string/ignore_ssl_errors" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="no_offline"
|
||||
android:summary="@string/disable_connectivity_check_summary"
|
||||
android:title="@string/disable_connectivity_check" />
|
||||
|
||||
</PreferenceScreen>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
@@ -44,6 +45,13 @@
|
||||
android:key="notifications_settings"
|
||||
android:title="@string/notifications_settings" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:dependency="tracker_enabled"
|
||||
android:key="tracker_no_nsfw"
|
||||
android:summary="@string/disable_nsfw_notifications_summary"
|
||||
android:title="@string/disable_nsfw_notifications" />
|
||||
|
||||
<Preference
|
||||
android:dependency="tracker_enabled"
|
||||
android:key="ignore_dose"
|
||||
@@ -51,7 +59,8 @@
|
||||
android:summary="@string/disable_battery_optimization_summary"
|
||||
android:title="@string/disable_battery_optimization"
|
||||
app:allowDividerAbove="true"
|
||||
app:isPreferenceVisible="false" />
|
||||
app:isPreferenceVisible="false"
|
||||
tools:isPrefrenceVisible="true" />
|
||||
|
||||
<org.koitharu.kotatsu.settings.utils.LinksPreference
|
||||
android:icon="@drawable/ic_info_outline"
|
||||
|
||||
19
app/src/main/res/xml/remote_action.xml
Normal file
19
app/src/main/res/xml/remote_action.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<remote-actions version="1.2">
|
||||
<action
|
||||
id="next_filter"
|
||||
label="@string/prev_page"
|
||||
priority="1"
|
||||
trigger_key="L">
|
||||
<preference name="gesture" value="swipe_left"/>
|
||||
</action>
|
||||
|
||||
<action
|
||||
id="prev_filter"
|
||||
label="@string/next_page"
|
||||
priority="2"
|
||||
trigger_key="R">
|
||||
<preference name="gesture" value="swipe_right"/>
|
||||
</action>
|
||||
|
||||
</remote-actions>
|
||||
@@ -4,7 +4,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.4.0'
|
||||
classpath 'com.android.tools.build:gradle:8.4.1'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24'
|
||||
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.51.1'
|
||||
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.24-1.0.20'
|
||||
|
||||
Reference in New Issue
Block a user