Compare commits

...

18 Commits
v5.1 ... v5.1.4

Author SHA1 Message Date
Koitharu
ef9b16da0b Fix favourite categories covers loading 2023-06-02 17:07:26 +03:00
Koitharu
5d1ef983e9 Check available ram before pages prefetch 2023-06-02 17:06:37 +03:00
Koitharu
eb78a776cf Update parsers 2023-06-02 17:04:46 +03:00
Koitharu
6881c22453 Update parsers 2023-05-27 17:54:31 +03:00
Koitharu
b5cd92fb5f Update parsers 2023-05-23 13:05:37 +03:00
Koitharu
08e5c148fd Limit cache max-age and action to clear cache manually 2023-05-23 09:07:56 +03:00
Koitharu
8323d399ff Fix focus changes on sync authorization screen 2023-05-23 09:07:16 +03:00
Koitharu
5108f45111 Limit lifetime of memory content cache 2023-05-23 09:06:22 +03:00
Koitharu
bf0d34e9cf Validate header value in settings 2023-05-23 09:04:57 +03:00
gallegonovato
3778a9e1d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Макар Разин
71ecd9d8e2 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (416 of 416 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (416 of 416 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Subham Jena
7cba8d2dc7 Translated using Weblate (Odia)
Currently translated at 3.8% (16 of 416 strings)

Translated using Weblate (Odia)

Currently translated at 2.4% (10 of 415 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
GpixeL
79c2927da2 Translated using Weblate (Indonesian)
Currently translated at 96.8% (402 of 415 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
ctntt
a4a28c7342 Translated using Weblate (German)
Currently translated at 99.2% (413 of 416 strings)

Translated using Weblate (German)

Currently translated at 99.5% (413 of 415 strings)

Co-authored-by: ctntt <pavlov_mainstreamed@slmail.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2023-05-17 18:51:56 +03:00
Koitharu
43a92bdf08 Improve sync logging 2023-05-17 17:00:42 +03:00
Koitharu
51ff1ff7b7 Fix concurrent chapters loading in reader 2023-05-17 16:17:18 +03:00
Koitharu
2e0eb5de54 Fix handling special characters in local manga filenames 2023-05-16 13:07:11 +03:00
Koitharu
4f68e7d0e6 Handle WebView unavailability 2023-05-16 07:56:05 +03:00
46 changed files with 460 additions and 92 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 544 versionCode 548
versionName '5.1' versionName '5.1.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -78,16 +78,16 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:cae7073f87') { implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.1' implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
@@ -96,7 +96,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0' implementation 'com.google.android.material:material:1.9.0'
@@ -115,8 +115,8 @@ dependencies {
implementation 'androidx.room:room-ktx:2.5.1' implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1' kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0' implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
@@ -127,8 +127,8 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.3.0' implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.3.0' implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@@ -136,21 +136,21 @@ dependencies {
implementation 'ch.acra:acra-http:5.9.7' implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7' implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227' testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.room:room-testing:2.5.1' androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'

View File

@@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null) get() = ConfigKey.Domain()
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -8,19 +8,22 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
if (recyclerView.hasPendingAdapterUpdates()) {
return
}
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) { if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return return
} }
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
val visibleItemCount = layoutManager.childCount val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) { if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView) onScrolledToEnd(recyclerView)
} }
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
} }
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
class ScrollListenerInvalidationObserver(
private val recyclerView: RecyclerView,
private val scrollListener: RecyclerView.OnScrollListener,
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
invalidateScroll()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
invalidateScroll()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
invalidateScroll()
}
private fun invalidateScroll() {
recyclerView.post {
scrollListener.onScrolled(recyclerView, 0, 0)
}
}
}

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.utils.ext.catchingWebViewUnavailability
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@@ -24,7 +25,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater)) if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run { supportActionBar?.run {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.Cache
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -88,14 +89,19 @@ interface AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient( fun provideHttpCache(
localStorageManager: LocalStorageManager, localStorageManager: LocalStorageManager,
): Cache = localStorageManager.createHttpCache()
@Provides
@Singleton
fun provideOkHttpClient(
cache: Cache,
commonHeadersInterceptor: CommonHeadersInterceptor, commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor, mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar, cookieJar: CookieJar,
settings: AppSettings, settings: AppSettings,
): OkHttpClient { ): OkHttpClient {
val cache = localStorageManager.createHttpCache()
return OkHttpClient.Builder().apply { return OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS)
@@ -106,6 +112,7 @@ interface AppModule {
bypassSSLErrors() bypassSSLErrors()
} }
cache(cache) cache(cache)
addNetworkInterceptor(CacheLimitInterceptor())
addInterceptor(GZipInterceptor()) addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor) addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())

View File

@@ -1,5 +0,0 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue<T>(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}

View File

@@ -6,6 +6,7 @@ import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@@ -13,8 +14,8 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
application.registerComponentCallbacks(this) application.registerComponentCallbacks(this)
} }
private val detailsCache = DeferredLruCache<Manga>(4) private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4) private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true override val isCachingEnabled: Boolean = true
@@ -23,7 +24,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
} }
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) { override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache.put(ContentCache.Key(source, url), details) detailsCache[ContentCache.Key(source, url)] = details
} }
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? { override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
@@ -31,7 +32,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
} }
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) { override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache.put(ContentCache.Key(source, url), pages) pagesCache[ContentCache.Key(source, url)] = pages
} }
override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onConfigurationChanged(newConfig: Configuration) = Unit
@@ -43,17 +44,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
trimCache(pagesCache, level) trimCache(pagesCache, level)
} }
private fun trimCache(cache: DeferredLruCache<*>, level: Int) { private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
when (level) { when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE, ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll() ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize() / 2) else -> cache.trimToSize(cache.maxSize / 2)
} }
} }
} }

View File

@@ -4,12 +4,15 @@ import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
@@ -82,6 +85,15 @@ class FileLogger(
flushImpl() flushImpl()
} }
@WorkerThread
fun flushBlocking() {
if (!isEnabled) {
return
}
runBlockingSafe { flushJob?.cancelAndJoin() }
runBlockingSafe { flushImpl() }
}
private fun postFlush() { private fun postFlush() {
if (flushJob?.isActive == true) { if (flushJob?.isActive == true) {
return return
@@ -96,10 +108,10 @@ class FileLogger(
} }
} }
private suspend fun flushImpl() { private suspend fun flushImpl() = withContext(NonCancellable) {
mutex.withLock { mutex.withLock {
if (buffer.isEmpty()) { if (buffer.isEmpty()) {
return return@withContext
} }
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) { if (file.length() > MAX_SIZE_BYTES) {
@@ -131,4 +143,9 @@ class FileLogger(
} }
bakFile.delete() bakFile.delete()
} }
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
runBlocking(NonCancellable) { block() }
} catch (_: InterruptedException) {
}
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.network
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.TimeUnit
class CacheLimitInterceptor : Interceptor {
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
private val defaultCacheControl = CacheControl.Builder()
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
.build()
.toString()
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val responseCacheControl = CacheControl.parse(response.headers)
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
return response
}
return response.newBuilder()
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
.build()
}
}

View File

@@ -13,6 +13,7 @@ object CommonHeaders {
const val CONTENT_ENCODING = "Content-Encoding" const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
val CACHE_CONTROL_NO_STORE: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()

View File

@@ -345,6 +345,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" 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" const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"

View File

@@ -6,6 +6,7 @@ import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@Dao @Dao
@@ -71,12 +72,12 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity> abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: SortOrder): List<String> { suspend fun findCovers(categoryId: Long, order: SortOrder): List<Cover> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery( val query = SimpleSQLiteQuery(
"SELECT m.cover_url FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " + "SELECT m.cover_url AS url, m.source AS source FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " +
"WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", "WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId), arrayOf<Any>(categoryId),
) )
@@ -145,7 +146,7 @@ abstract class FavouritesDao {
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>> protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
@RawQuery @RawQuery
protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List<String> protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List<Cover>
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId") @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -66,11 +67,11 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<String>>> { fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.favouriteCategoriesDao.observeAll() return db.favouriteCategoriesDao.observeAll()
.map { .map {
db.withTransaction { db.withTransaction {
val res = LinkedHashMap<FavouriteCategory, List<String>>() val res = LinkedHashMap<FavouriteCategory, List<Cover>>()
for (entity in it) { for (entity in it) {
val cat = entity.toFavouriteCategory() val cat = entity.toFavouriteCategory()
res[cat] = db.favouritesDao.findCovers( res[cat] = db.favouritesDao.findCovers(

View File

@@ -18,11 +18,12 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.animatorDurationScale
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
fun categoryAD( fun categoryAD(
coil: ImageLoader, coil: ImageLoader,
@@ -53,10 +54,7 @@ fun categoryAD(
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)) ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
val fallback = ColorDrawable(Color.TRANSPARENT) val fallback = ColorDrawable(Color.TRANSPARENT)
val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3) val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
val crossFadeDuration = ( val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
context.resources.getInteger(R.integer.config_defaultAnimTime) *
context.animatorDurationScale
).toInt()
itemView.setOnClickListener(eventListener) itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener) itemView.setOnLongClickListener(eventListener)
itemView.setOnTouchListener(eventListener) itemView.setOnTouchListener(eventListener)
@@ -77,9 +75,11 @@ fun categoryAD(
) )
} }
repeat(coverViews.size) { i -> repeat(coverViews.size) { i ->
coverViews[i].newImageRequest(lifecycleOwner, item.covers.getOrNull(i))?.run { val cover = item.covers.getOrNull(i)
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(fallback) fallback(fallback)
source(cover?.mangaSource)
crossfade(crossFadeDuration * (i + 1)) crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)
allowRgb565(true) allowRgb565(true)

View File

@@ -1,11 +1,12 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
class CategoryListModel( class CategoryListModel(
val mangaCount: Int, val mangaCount: Int,
val covers: List<String>, val covers: List<Cover>,
val category: FavouriteCategory, val category: FavouriteCategory,
val isReorderMode: Boolean, val isReorderMode: Boolean,
) : ListModel { ) : ListModel {
@@ -21,9 +22,7 @@ class CategoryListModel(
if (covers != other.covers) return false if (covers != other.covers) return false
if (category.id != other.category.id) return false if (category.id != other.category.id) return false
if (category.title != other.category.title) return false if (category.title != other.category.title) return false
if (category.order != other.category.order) return false return category.order == other.category.order
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -35,4 +34,4 @@ class CategoryListModel(
result = 31 * result + category.order.hashCode() result = 31 * result + category.order.hashCode()
return result return result
} }
} }

View File

@@ -54,9 +54,10 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) { suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try { try {
file.sink(append = false).buffer().use { val bytes = file.sink(append = false).buffer().use {
it.writeAllCancellable(source) it.writeAllCancellable(source)
} }
check(bytes != 0L) { "No data has been written" }
lruCache.get().put(url, file) lruCache.get().put(url, file)
} finally { } finally {
file.delete() file.delete()

View File

@@ -31,7 +31,8 @@ sealed class LocalMangaInput(
} }
@JvmStatic @JvmStatic
protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString()
@JvmStatic @JvmStatic
protected fun Manga.copy2( protected fun Manga.copy2(

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
@@ -8,6 +9,7 @@ import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -30,8 +32,10 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.utils.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.ramAvailable
import org.koitharu.kotatsu.utils.ext.withProgress import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File import java.io.File
@@ -44,9 +48,11 @@ import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10 private const val PREFETCH_LIMIT_DEFAULT = 10
private const val PREFETCH_MIN_RAM_MB = 80L
@ActivityRetainedScoped @ActivityRetainedScoped
class PageLoader @Inject constructor( class PageLoader @Inject constructor(
@ApplicationContext private val context: Context,
lifecycle: ActivityRetainedLifecycle, lifecycle: ActivityRetainedLifecycle,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
@@ -75,7 +81,7 @@ class PageLoader @Inject constructor(
} }
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled() return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled() && !isLowRam()
} }
@AnyThread @AnyThread
@@ -116,6 +122,9 @@ class PageLoader @Inject constructor(
suspend fun convertInPlace(file: File) { suspend fun convertInPlace(file: File) {
convertLock.withLock { convertLock.withLock {
if (context.ramAvailable < file.length() * 2) {
return@withLock
}
runInterruptible(Dispatchers.Default) { runInterruptible(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath) val image = BitmapFactory.decodeFile(file.absolutePath)
try { try {
@@ -212,6 +221,10 @@ class PageLoader @Inject constructor(
} }
} }
private fun isLowRam(): Boolean {
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
}
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler { CoroutineExceptionHandler {

View File

@@ -252,6 +252,7 @@ class ReaderViewModel @Inject constructor(
val prevJob = stateChangeJob val prevJob = stateChangeJob
stateChangeJob = launchJob(Dispatchers.Default) { stateChangeJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
loadingJob?.join()
val pages = content.value?.pages ?: return@launchJob val pages = content.value?.pages ?: return@launchJob
pages.getOrNull(position)?.let { page -> pages.getOrNull(position)?.let { page ->
currentState.update { cs -> currentState.update { cs ->
@@ -263,12 +264,12 @@ class ReaderViewModel @Inject constructor(
return@launchJob return@launchJob
} }
ensureActive() ensureActive()
if (position <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true) loadPrevNextChapter(pages.last().chapterId, isNext = true)
} }
if (position <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
if (pageLoader.isPrefetchApplicable()) { if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
} }
@@ -348,7 +349,9 @@ class ReaderViewModel @Inject constructor(
@AnyThread @AnyThread
private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) {
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.join()
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
content.emitValue(ReaderContent(chaptersLoader.snapshot(), null)) content.emitValue(ReaderContent(chaptersLoader.snapshot(), null))
} }

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.settings
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.EditTextValidator
class HeaderValidator : EditTextValidator() {
private val headers = Headers.Builder()
override fun validate(text: String): ValidationResult {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return ValidationResult.Success
}
return if (!validateImpl(trimmed)) {
ValidationResult.Failed(context.getString(R.string.invalid_value_message))
} else {
ValidationResult.Success
}
}
private fun validateImpl(value: String): Boolean = runCatching {
headers[CommonHeaders.USER_AGENT] = value
}.isSuccess
}

View File

@@ -8,7 +8,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
@@ -39,6 +42,9 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
@Inject
lateinit var cache: Cache
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var shortcutsUpdater: ShortcutsUpdater
@@ -52,6 +58,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES) findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES)
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS) findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS)
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindSummaryToHttpCacheSize()
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewLifecycleScope.launch { viewLifecycleScope.launch {
lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED) lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
@@ -90,6 +97,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
true true
} }
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
clearHttpCache()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> { AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewLifecycleScope.launch { viewLifecycleScope.launch {
trackerRepo.clearLogs() trackerRepo.clearLogs()
@@ -131,6 +143,32 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
summary = FileSize.BYTES.format(context, size) summary = FileSize.BYTES.format(context, size)
} }
private fun Preference.bindSummaryToHttpCacheSize() = viewLifecycleScope.launch {
val size = runInterruptible(Dispatchers.IO) { cache.size() }
summary = FileSize.BYTES.format(context, size)
}
private fun clearHttpCache() {
val preference = findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR) ?: return
val ctx = preference.context.applicationContext
viewLifecycleScope.launch {
try {
preference.isEnabled = false
val size = runInterruptible(Dispatchers.IO) {
cache.evictAll()
cache.size()
}
preference.summary = FileSize.BYTES.format(ctx, size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources)
} finally {
preference.isEnabled = true
}
}
}
private fun clearSearchHistory(preference: Preference) { private fun clearSearchHistory(preference: Preference) {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)

View File

@@ -19,10 +19,12 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
val preference: Preference = when (key) { val preference: Preference = when (key) {
is ConfigKey.Domain -> { is ConfigKey.Domain -> {
val presetValues = key.presetValues val presetValues = key.presetValues
if (presetValues.isNullOrEmpty()) { if (presetValues.size <= 1) {
EditTextPreference(requireContext()) EditTextPreference(requireContext())
} else { } else {
AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues } AutoCompleteTextViewPreference(requireContext()).apply {
entries = presetValues.toStringArray()
}
}.apply { }.apply {
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
setOnBindEditTextListener( setOnBindEditTextListener(
@@ -44,7 +46,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
EditTextBindListener( EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_TEXT, inputType = EditorInfo.TYPE_CLASS_TEXT,
hint = key.defaultValue, hint = key.defaultValue,
validator = null, validator = HeaderValidator(),
), ),
) )
setTitle(R.string.user_agent) setTitle(R.string.user_agent)
@@ -64,3 +66,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
screen.addPreference(preference) screen.addPreference(preference)
} }
} }
private fun Array<out String>.toStringArray(): Array<String> {
return Array(size) { i -> this[i] as? String ?: "" }
}

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat import org.koitharu.kotatsu.utils.ext.getSerializableExtraCompat
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -41,7 +42,9 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater)) if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource
if (source == null) { if (source == null) {
finishAfterTransition() finishAfterTransition()

View File

@@ -13,6 +13,7 @@ import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -22,7 +23,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.TABLE_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS
import org.koitharu.kotatsu.core.logs.LoggersModule
import org.koitharu.kotatsu.core.network.GZipInterceptor import org.koitharu.kotatsu.core.network.GZipInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncAuthenticator
@@ -61,6 +64,7 @@ class SyncHelper(
} }
private val defaultGcPeriod: Long // gc period if sync enabled private val defaultGcPeriod: Long // gc period if sync enabled
get() = TimeUnit.DAYS.toMillis(4) get() = TimeUnit.DAYS.toMillis(4)
private val logger = LoggersModule.provideSyncLogger(context, AppSettings(context))
fun syncFavourites(syncResult: SyncResult) { fun syncFavourites(syncResult: SyncResult) {
val data = JSONObject() val data = JSONObject()
@@ -71,7 +75,7 @@ class SyncHelper(
.url("$baseUrl/resource/$TABLE_FAVOURITES") .url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody()) .post(data.toRequestBody())
.build() .build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
if (response != null) { if (response != null) {
val timestamp = response.getLong(FIELD_TIMESTAMP) val timestamp = response.getLong(FIELD_TIMESTAMP)
val categoriesResult = val categoriesResult =
@@ -93,7 +97,7 @@ class SyncHelper(
.url("$baseUrl/resource/$TABLE_HISTORY") .url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody()) .post(data.toRequestBody())
.build() .build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
if (response != null) { if (response != null) {
val result = upsertHistory( val result = upsertHistory(
json = response.getJSONArray(TABLE_HISTORY), json = response.getJSONArray(TABLE_HISTORY),
@@ -105,6 +109,19 @@ class SyncHelper(
gcHistory() gcHistory()
} }
fun onError(e: Throwable) {
if (logger.isEnabled) {
logger.log("Sync error", e)
}
}
fun onSyncComplete(result: SyncResult) {
if (logger.isEnabled) {
logger.log("Sync finshed: ${result.toDebugString()}")
logger.flushBlocking()
}
}
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY) val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
@@ -298,4 +315,10 @@ class SyncHelper(
private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject
private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray
private fun Response.log() = apply {
if (logger.isEnabled) {
logger.log("$code ${request.url}")
}
}
} }

View File

@@ -84,12 +84,14 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
binding.groupLogin.isVisible = false binding.groupLogin.isVisible = false
binding.groupPassword.isVisible = true binding.groupPassword.isVisible = true
pageBackCallback.update() pageBackCallback.update()
binding.editPassword.requestFocus()
} }
R.id.button_back -> { R.id.button_back -> {
binding.groupPassword.isVisible = false binding.groupPassword.isVisible = false
binding.groupLogin.isVisible = true binding.groupLogin.isVisible = true
pageBackCallback.update() pageBackCallback.update()
binding.editEmail.requestFocus()
} }
R.id.button_done -> { R.id.button_done -> {
@@ -200,6 +202,7 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
binding.groupLogin.isVisible = true binding.groupLogin.isVisible = true
binding.groupPassword.isVisible = false binding.groupPassword.isVisible = false
binding.editEmail.requestFocus()
update() update()
} }

View File

@@ -14,8 +14,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.db.* import org.koitharu.kotatsu.core.db.*
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.logs.SyncLogger
import java.util.concurrent.Callable import java.util.concurrent.Callable
abstract class SyncProvider : ContentProvider() { abstract class SyncProvider : ContentProvider() {
@@ -24,7 +22,6 @@ abstract class SyncProvider : ContentProvider() {
EntryPointAccessors.fromApplication(checkNotNull(context), SyncProviderEntryPoint::class.java) EntryPointAccessors.fromApplication(checkNotNull(context), SyncProviderEntryPoint::class.java)
} }
private val database by lazy { entryPoint.database } private val database by lazy { entryPoint.database }
private val logger by lazy { entryPoint.logger }
private val supportedTables = setOf( private val supportedTables = setOf(
TABLE_FAVOURITES, TABLE_FAVOURITES,
@@ -52,7 +49,6 @@ abstract class SyncProvider : ContentProvider() {
.selection(selection, selectionArgs) .selection(selection, selectionArgs)
.orderBy(sortOrder) .orderBy(sortOrder)
.create() .create()
logger.log("query: ${sqlQuery.sql} (${selectionArgs.contentToString()})")
return database.openHelper.readableDatabase.query(sqlQuery) return database.openHelper.readableDatabase.query(sqlQuery)
} }
@@ -65,7 +61,6 @@ abstract class SyncProvider : ContentProvider() {
if (values == null || table == null) { if (values == null || table == null) {
return null return null
} }
logger.log { "insert: $table [$values]" }
val db = database.openHelper.writableDatabase val db = database.openHelper.writableDatabase
if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) { if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) {
db.update(table, values) db.update(table, values)
@@ -75,7 +70,6 @@ abstract class SyncProvider : ContentProvider() {
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int { override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri) ?: return 0 val table = getTableName(uri) ?: return 0
logger.log { "delete: $table ($selection) : (${selectionArgs.contentToString()})" }
return database.openHelper.writableDatabase.delete(table, selection, selectionArgs) return database.openHelper.writableDatabase.delete(table, selection, selectionArgs)
} }
@@ -84,7 +78,6 @@ abstract class SyncProvider : ContentProvider() {
if (values == null || table == null) { if (values == null || table == null) {
return 0 return 0
} }
logger.log { "update: $table ($selection) : (${selectionArgs.contentToString()}) [$values]" }
return database.openHelper.writableDatabase return database.openHelper.writableDatabase
.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs) .update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs)
} }
@@ -127,8 +120,5 @@ abstract class SyncProvider : ContentProvider() {
interface SyncProviderEntryPoint { interface SyncProviderEntryPoint {
val database: MangaDatabase val database: MangaDatabase
@get:SyncLogger
val logger: FileLogger
} }
} }

View File

@@ -28,6 +28,10 @@ class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(cont
runCatchingCancellable { runCatchingCancellable {
syncHelper.syncFavourites(syncResult) syncHelper.syncFavourites(syncResult)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure(syncResult::onError) }.onFailure { e ->
syncResult.onError(e)
syncHelper.onError(e)
}
syncHelper.onSyncComplete(syncResult)
} }
} }

View File

@@ -28,6 +28,10 @@ class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context
runCatchingCancellable { runCatchingCancellable {
syncHelper.syncHistory(syncResult) syncHelper.syncHistory(syncResult)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure(syncResult::onError) }.onFailure { e ->
syncResult.onError(e)
syncHelper.onError(e)
}
syncHelper.onSyncComplete(syncResult)
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.utils
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -84,6 +85,7 @@ class ShareHelper(private val context: Context) {
fun shareLogs(loggers: Collection<FileLogger>) { fun shareLogs(loggers: Collection<FileLogger>) {
val intentBuilder = ShareCompat.IntentBuilder(context) val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_TEXT) .setType(TYPE_TEXT)
var hasLogs = false
for (logger in loggers) { for (logger in loggers) {
val logFile = logger.file val logFile = logger.file
if (!logFile.exists()) { if (!logFile.exists()) {
@@ -91,8 +93,13 @@ class ShareHelper(private val context: Context) {
} }
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile) val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
intentBuilder.addStream(uri) intentBuilder.addStream(uri)
hasLogs = true
}
if (hasLogs) {
intentBuilder.setChooserTitle(R.string.share_logs)
intentBuilder.startChooser()
} else {
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
} }
intentBuilder.setChooserTitle(R.string.share_logs)
intentBuilder.startChooser()
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity import android.app.Activity
import android.app.ActivityManager import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
@@ -19,6 +20,7 @@ import android.provider.Settings
import android.view.View import android.view.View
import android.view.ViewPropertyAnimator import android.view.ViewPropertyAnimator
import android.view.Window import android.view.Window
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes import androidx.annotation.IntegerRes
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
@@ -137,6 +139,13 @@ fun isLowRamDevice(context: Context): Boolean {
return context.activityManager?.isLowRamDevice ?: false return context.activityManager?.isLowRamDevice ?: false
} }
val Context.ramAvailable: Long
get() {
val result = MemoryInfo()
activityManager?.getMemoryInfo(result)
return result.availMem
}
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation( fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation(
view, view,
0, 0,
@@ -170,3 +179,18 @@ fun Context.findActivity(): Activity? = when (this) {
is ContextWrapper -> baseContext.findActivity() is ContextWrapper -> baseContext.findActivity()
else -> null else -> null
} }
inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
return try {
block()
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import android.util.AndroidRuntimeException
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okio.FileNotFoundException import okio.FileNotFoundException
@@ -94,3 +95,8 @@ inline fun <R> runCatchingCancellable(block: () -> R): Result<R> {
Result.failure(e) Result.failure(e)
} }
} }
fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true
}

View File

@@ -54,6 +54,11 @@ var RecyclerView.firstVisibleItemPosition: Int
} }
} }
val RecyclerView.visibleItemCount: Int
get() = (layoutManager as? LinearLayoutManager)?.run {
findLastVisibleItemPosition() - findFirstVisibleItemPosition()
} ?: 0
fun View.hasGlobalPoint(x: Int, y: Int): Boolean { fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
if (visibility != View.VISIBLE) { if (visibility != View.VISIBLE) {
return false return false

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource
class Cover(
val url: String,
val source: String,
) {
val mangaSource: MangaSource?
get() = if (source.isEmpty()) null else MangaSource.values().find { it.name == source }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cover
if (url != other.url) return false
return source == other.source
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + source.hashCode()
return result
}
override fun toString(): String {
return "Cover(url='$url', source=$source)"
}
}

View File

@@ -410,4 +410,6 @@
<string name="downloads_paused">Спампоўкі прыпыненыя</string> <string name="downloads_paused">Спампоўкі прыпыненыя</string>
<string name="downloads_removed">Спампоўкі выдалены</string> <string name="downloads_removed">Спампоўкі выдалены</string>
<string name="downloads_cancelled">Спампоўкі былі адменены</string> <string name="downloads_cancelled">Спампоўкі былі адменены</string>
<string name="translations">Пераклады</string>
<string name="web_view_unavailable">WebView недаступны: праверце, ці ўсталяваны пастаўшчык WebView</string>
</resources> </resources>

View File

@@ -68,7 +68,7 @@
<string name="zoom_mode_keep_start">Am Anfang ausrichten</string> <string name="zoom_mode_keep_start">Am Anfang ausrichten</string>
<string name="zoom_mode_fit_width">An Breite anpassen</string> <string name="zoom_mode_fit_width">An Breite anpassen</string>
<string name="zoom_mode_fit_height">An Höhe anpassen</string> <string name="zoom_mode_fit_height">An Höhe anpassen</string>
<string name="black_dark_theme_summary">Spart Energie bei AMOLED-Bildschirme</string> <string name="black_dark_theme_summary">Spart Energie auf AMOLED-Bildschirmen</string>
<string name="black_dark_theme">Reines Schwarz</string> <string name="black_dark_theme">Reines Schwarz</string>
<string name="right_to_left">Von rechts nach links</string> <string name="right_to_left">Von rechts nach links</string>
<string name="create_category">Neue Kategorie</string> <string name="create_category">Neue Kategorie</string>
@@ -91,17 +91,17 @@
<string name="confirm">Bestätigen</string> <string name="confirm">Bestätigen</string>
<string name="protect_application_subtitle">Gib ein Passwort ein, mit dem die App gestartet werden soll</string> <string name="protect_application_subtitle">Gib ein Passwort ein, mit dem die App gestartet werden soll</string>
<string name="next">Weiter</string> <string name="next">Weiter</string>
<string name="text_clear_search_history_prompt">Möchtest du wirklich alle letzten Suchanfragen entfernen\?</string> <string name="text_clear_search_history_prompt">Möchtest du alle letzten Suchanfragen unwiderruflich entfernen\?</string>
<string name="read_more">Mehr erfahren</string> <string name="read_more">Mehr erfahren</string>
<string name="tracker_warning">Einige Geräte haben ein anderes Systemverhalten, welches womöglich Hintergrundprozesse unterbricht.</string> <string name="tracker_warning">Einige Geräte haben ein anderes Systemverhalten, welches womöglich Hintergrundprozesse unterbricht.</string>
<string name="backup_saved">Sicherung erfolgreich gespeichert</string> <string name="backup_saved">Backup gespeichert</string>
<string name="welcome">Willkommen</string> <string name="welcome">Willkommen</string>
<string name="remove_category">Entfernen</string> <string name="remove_category">Entfernen</string>
<string name="rotate_screen">Bildschirm drehen</string> <string name="rotate_screen">Bildschirm drehen</string>
<string name="size_s">Größe: %s</string> <string name="size_s">Größe: %s</string>
<string name="new_version_s">Neue Version: %s</string> <string name="new_version_s">Neue Version: %s</string>
<string name="search_results">Suchergebnisse</string> <string name="search_results">Suchergebnisse</string>
<string name="text_feed_holder">Hier siehst du die neuen Kapitel des Mangas, den du gerade liest</string> <string name="text_feed_holder">Hier siehst du neue Kapitel der Mangas, die du liest</string>
<string name="read_later">Später lesen</string> <string name="read_later">Später lesen</string>
<string name="favourites_category_empty">Diese Kategorie ist leer</string> <string name="favourites_category_empty">Diese Kategorie ist leer</string>
<string name="all_favourites">Alle Favoriten</string> <string name="all_favourites">Alle Favoriten</string>
@@ -115,7 +115,7 @@
<string name="text_local_holder_secondary">Speicher Manga aus Online-Quellen oder importiere Dateien.</string> <string name="text_local_holder_secondary">Speicher Manga aus Online-Quellen oder importiere Dateien.</string>
<string name="text_local_holder_primary">Speichere erst etwas</string> <string name="text_local_holder_primary">Speichere erst etwas</string>
<string name="text_history_holder_secondary">Was du lesen kannst, findest du im Seitenmenü.</string> <string name="text_history_holder_secondary">Was du lesen kannst, findest du im Seitenmenü.</string>
<string name="text_search_holder_secondary">Versuche, die Abfrage umzuformulieren.</string> <string name="text_search_holder_secondary">Versuche, die Anfrage umzuformulieren.</string>
<string name="favourites_categories">Favoriten-Kategorien</string> <string name="favourites_categories">Favoriten-Kategorien</string>
<string name="vibration">Vibration</string> <string name="vibration">Vibration</string>
<string name="light_indicator">LED-Anzeige</string> <string name="light_indicator">LED-Anzeige</string>
@@ -130,7 +130,7 @@
<string name="open_in_browser">Im Browser öffnen</string> <string name="open_in_browser">Im Browser öffnen</string>
<string name="external_storage">Externer Speicher</string> <string name="external_storage">Externer Speicher</string>
<string name="internal_storage">Interner Speicher</string> <string name="internal_storage">Interner Speicher</string>
<string name="search_history_cleared">Suchverlauf gelöscht</string> <string name="search_history_cleared">Gelöscht</string>
<string name="clear_search_history">Suchverlauf löschen</string> <string name="clear_search_history">Suchverlauf löschen</string>
<string name="clear_thumbs_cache">Miniaturansichten Cache löschen</string> <string name="clear_thumbs_cache">Miniaturansichten Cache löschen</string>
<string name="error">Fehler</string> <string name="error">Fehler</string>
@@ -158,14 +158,14 @@
<string name="clear">Löschen</string> <string name="clear">Löschen</string>
<string name="taps_on_edges">Rand antippen</string> <string name="taps_on_edges">Rand antippen</string>
<string name="captcha_solve">Lösen</string> <string name="captcha_solve">Lösen</string>
<string name="captcha_required">CAPTCHA ist erforderlich</string> <string name="captcha_required">CAPTCHA erforderlich</string>
<string name="silent">Stumm</string> <string name="silent">Stumm</string>
<string name="reader_mode_hint">Die gewählte Konfiguration wird für diesen Manga gespeichert</string> <string name="reader_mode_hint">Die gewählte Konfiguration wird für diesen Manga gespeichert</string>
<string name="tap_to_try_again">Tippe, um es erneut zu versuchen</string> <string name="tap_to_try_again">Tippe, um es erneut zu versuchen</string>
<string name="today">Heute</string> <string name="today">Heute</string>
<string name="long_ago">Vor langer Zeit</string> <string name="long_ago">Vor langer Zeit</string>
<string name="yesterday">Gestern</string> <string name="yesterday">Gestern</string>
<string name="backup_information">Du kannst eine Sicherung deines Verlaufs und deiner Favoriten erstellen und diese wiederherstellen</string> <string name="backup_information">Du kannst ein Backup deines Verlaufs und deiner Favoriten erstellen und wiederherstellen</string>
<string name="data_restored_with_errors">Die Daten wurden wiederhergestellt, aber es gibt Fehler</string> <string name="data_restored_with_errors">Die Daten wurden wiederhergestellt, aber es gibt Fehler</string>
<string name="data_restored_success">Alle Daten wiederhergestellt</string> <string name="data_restored_success">Alle Daten wiederhergestellt</string>
<string name="zoom_mode_fit_center">An Zentrum anpassen</string> <string name="zoom_mode_fit_center">An Zentrum anpassen</string>
@@ -181,11 +181,11 @@
<string name="dont_check">Nicht prüfen</string> <string name="dont_check">Nicht prüfen</string>
<string name="domain">Domäne</string> <string name="domain">Domäne</string>
<string name="gestures_only">Nur Gesten</string> <string name="gestures_only">Nur Gesten</string>
<string name="chapter_is_missing">Kapitel fehlt</string> <string name="chapter_is_missing">Das Kapitel fehlt</string>
<string name="chapter_is_missing_text">Lade dieses fehlende Kapitel herunter oder lese es online.</string> <string name="chapter_is_missing_text">Lade dieses fehlende Kapitel herunter oder lese es online.</string>
<string name="queued">In Warteschlange</string> <string name="queued">In Warteschlange</string>
<string name="about_app_translation">Übersetzung</string> <string name="about_app_translation">Übersetzung</string>
<string name="about_app_translation_summary">Übersetze diese App</string> <string name="about_app_translation_summary">Übersetze die App</string>
<string name="text_clear_cookies_prompt">Du wirst von allen Quellen abgemeldet</string> <string name="text_clear_cookies_prompt">Du wirst von allen Quellen abgemeldet</string>
<string name="genres">Genres</string> <string name="genres">Genres</string>
<string name="auth_not_supported_by">Anmeldung bei %s wird nicht unterstützt</string> <string name="auth_not_supported_by">Anmeldung bei %s wird nicht unterstützt</string>
@@ -316,7 +316,7 @@
<string name="reorder">Neu anordnen</string> <string name="reorder">Neu anordnen</string>
<string name="empty">Leer</string> <string name="empty">Leer</string>
<string name="explore">Erkunden</string> <string name="explore">Erkunden</string>
<string name="confirm_exit">Drücke zum Verlassen erneut auf Zurück</string> <string name="confirm_exit">Drücke zum Beenden erneut Zurück</string>
<string name="exit_confirmation_summary">Drücke zweimal Zurück, um die App zu beenden</string> <string name="exit_confirmation_summary">Drücke zweimal Zurück, um die App zu beenden</string>
<string name="feed">Feed</string> <string name="feed">Feed</string>
<string name="incognito_mode">Inkognito-Modus</string> <string name="incognito_mode">Inkognito-Modus</string>
@@ -398,16 +398,18 @@
<string name="remove_completed">Entfernung abgeschlossen</string> <string name="remove_completed">Entfernung abgeschlossen</string>
<string name="cancel_all">Alle abbrechen</string> <string name="cancel_all">Alle abbrechen</string>
<string name="downloads_wifi_only">Nur über Wi-Fi herunterladen</string> <string name="downloads_wifi_only">Nur über Wi-Fi herunterladen</string>
<string name="downloads_wifi_only_summary">Beenden des Herunterladens beim Wechsel zu einem Mobilfunknetz</string> <string name="downloads_wifi_only_summary">Beende das Herunterladen beim Wechsel zu einem Mobilfunknetz</string>
<string name="suggestions_notifications_summary">Zeige ab und zu Benachrichtigungen mit Manga-Vorschlägen</string> <string name="suggestions_notifications_summary">Zeige ab und zu Benachrichtigungen mit Manga-Vorschlägen</string>
<string name="downloads_removed">Downloads wurden entfernt</string> <string name="downloads_removed">Downloads wurden entfernt</string>
<string name="suggestion_manga">Vorschlag: %s</string> <string name="suggestion_manga">Vorschlag: %s</string>
<string name="more">Mehr</string> <string name="more">Mehr</string>
<string name="cancel_all_downloads_confirm">Alle Downloads werden abgebrochen, teilweise heruntergeladene Dateien gehen verloren</string> <string name="cancel_all_downloads_confirm">Alle Downloads werden abgebrochen, teilweise heruntergeladene Dateien gehen verloren</string>
<string name="remove_completed_downloads_confirm">Dein Downloads-Verlauf wird unwiderruflich gelöscht sein</string> <string name="remove_completed_downloads_confirm">Dein Downloads-Verlauf wird unwiderruflich gelöscht</string>
<string name="text_downloads_list_holder">Du hast keine Downloads</string> <string name="text_downloads_list_holder">Du hast keine Downloads</string>
<string name="downloads_resumed">Downloads wurden fortgesetzt</string> <string name="downloads_resumed">Downloads wurden fortgesetzt</string>
<string name="downloads_paused">Downloads wurden pausiert</string> <string name="downloads_paused">Downloads wurden pausiert</string>
<string name="downloads_cancelled">Downloads wurden abgebrochen</string> <string name="downloads_cancelled">Downloads wurden abgebrochen</string>
<string name="suggestions_enable_prompt">Willst du personalisierte Manga-Vorschläge erhalten\?</string> <string name="suggestions_enable_prompt">Willst du personalisierte Manga-Vorschläge erhalten\?</string>
<string name="translations">Übersetzungen</string>
<string name="web_view_unavailable">WebView nicht verfügbar: überprüfe, ob WebView installiert ist</string>
</resources> </resources>

View File

@@ -410,4 +410,6 @@
<string name="downloads_removed">Las descargas se han eliminado</string> <string name="downloads_removed">Las descargas se han eliminado</string>
<string name="downloads_cancelled">Las descargas se han cancelado</string> <string name="downloads_cancelled">Las descargas se han cancelado</string>
<string name="suggestions_enable_prompt">¿Quieres recibir sugerencias sobre mangas personalizadas\?</string> <string name="suggestions_enable_prompt">¿Quieres recibir sugerencias sobre mangas personalizadas\?</string>
<string name="translations">Traducciones</string>
<string name="web_view_unavailable">WebView no está disponible: comprueba si el proveedor de WebView está instalado</string>
</resources> </resources>

View File

@@ -263,7 +263,7 @@
<string name="tracking">Pelacakan</string> <string name="tracking">Pelacakan</string>
<string name="logout">Keluar</string> <string name="logout">Keluar</string>
<string name="sync">Sinkronisasi</string> <string name="sync">Sinkronisasi</string>
<string name="send">Terkirim</string> <string name="send">Kirim</string>
<string name="status_reading">Dibaca</string> <string name="status_reading">Dibaca</string>
<string name="status_on_hold">Ditunda</string> <string name="status_on_hold">Ditunda</string>
<string name="invalid_domain_message">Domain tidak valid</string> <string name="invalid_domain_message">Domain tidak valid</string>

View File

@@ -4,4 +4,11 @@
<string name="details">ଵିଵରଣୀ</string> <string name="details">ଵିଵରଣୀ</string>
<string name="settings">ସେଟିଂ</string> <string name="settings">ସେଟିଂ</string>
<string name="suggestion_manga">ପରାମର୍ଶ: %s</string> <string name="suggestion_manga">ପରାମର୍ଶ: %s</string>
<string name="done">ହେଲା</string>
<string name="services">ସେଵା</string>
<string name="got_it">ବୁଝିଗଲି</string>
<string name="black_dark_theme">କଳା</string>
<string name="theme">ଥିମ୍</string>
<string name="speed">ଵେଗ</string>
<string name="download_started">ଡାଉନଲୋଡ୍ ଆରମ୍ଭ ହେଲା</string>
</resources> </resources>

View File

@@ -410,4 +410,6 @@
<string name="suggestions_notifications_summary">Иногда показывать уведомления с рекомендуемой мангой</string> <string name="suggestions_notifications_summary">Иногда показывать уведомления с рекомендуемой мангой</string>
<string name="remove_completed_downloads_confirm">Ваша история загрузок будет удалена</string> <string name="remove_completed_downloads_confirm">Ваша история загрузок будет удалена</string>
<string name="suggestions_enable_prompt">Хотите ли Вы получать персонализированные рекомендации манги\?</string> <string name="suggestions_enable_prompt">Хотите ли Вы получать персонализированные рекомендации манги\?</string>
<string name="translations">Переводы</string>
<string name="web_view_unavailable">WebView недоступен: проверьте, установлен ли провайдер WebView</string>
</resources> </resources>

View File

@@ -410,4 +410,6 @@
<string name="downloads_removed">Завантаження видалено</string> <string name="downloads_removed">Завантаження видалено</string>
<string name="downloads_cancelled">Завантаження скасовано</string> <string name="downloads_cancelled">Завантаження скасовано</string>
<string name="suggestions_enable_prompt">Хочете отримувати персоналізовані пропозиції щодо манги\?</string> <string name="suggestions_enable_prompt">Хочете отримувати персоналізовані пропозиції щодо манги\?</string>
<string name="web_view_unavailable">WebView недоступний: перевірте, чи встановлено провайдер WebView</string>
<string name="translations">Переклади</string>
</resources> </resources>

View File

@@ -415,4 +415,7 @@
<string name="downloads_cancelled">Downloads have been cancelled</string> <string name="downloads_cancelled">Downloads have been cancelled</string>
<string name="suggestions_enable_prompt">Do you want to receive personalized manga suggestions?</string> <string name="suggestions_enable_prompt">Do you want to receive personalized manga suggestions?</string>
<string name="translations">Translations</string> <string name="translations">Translations</string>
<string name="web_view_unavailable">WebView not available: check if WebView provider is installed</string>
<string name="invalid_value_message">Invalid value</string>
<string name="clear_network_cache">Clear network cache</string>
</resources> </resources>

View File

@@ -39,6 +39,12 @@
android:summary="@string/computing_" android:summary="@string/computing_"
android:title="@string/clear_pages_cache" /> android:title="@string/clear_pages_cache" />
<Preference
android:key="http_cache_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_network_cache" />
<Preference <Preference
android:key="cookies_clear" android:key="cookies_clear"
android:persistent="false" android:persistent="false"

View File

@@ -15,7 +15,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost", null) get() = ConfigKey.Domain("localhost")
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)