From bf0d34e9cfc334bc0c6be389c8c3d6ab7a5c2eff Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 23 May 2023 09:04:57 +0300 Subject: [PATCH 1/5] Validate header value in settings --- .../kotatsu/settings/HeaderValidator.kt | 27 +++++++++++++++++++ .../kotatsu/settings/SourceSettingsExt.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/settings/HeaderValidator.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HeaderValidator.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HeaderValidator.kt new file mode 100644 index 000000000..9e251c0a7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HeaderValidator.kt @@ -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 +} diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt index 5137fe95e..293f4847d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt @@ -44,7 +44,7 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT, hint = key.defaultValue, - validator = null, + validator = HeaderValidator(), ), ) setTitle(R.string.user_agent) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 406703d2e..6521d51d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -416,4 +416,5 @@ Do you want to receive personalized manga suggestions? Translations WebView not available: check if WebView provider is installed + Invalid value From 5108f4511145e50f8ec12777a8486b4ee47ef01f Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 20 May 2023 15:47:47 +0300 Subject: [PATCH 2/5] Limit lifetime of memory content cache --- .../kotatsu/core/cache/DeferredLruCache.kt | 5 --- .../kotatsu/core/cache/ExpiringLruCache.kt | 33 ++++++++++++++++++ .../kotatsu/core/cache/ExpiringValue.kt | 34 +++++++++++++++++++ .../kotatsu/core/cache/MemoryContentCache.kt | 15 ++++---- 4 files changed, 75 insertions(+), 12 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringValue.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt deleted file mode 100644 index 8b9e08aa3..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/DeferredLruCache.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import androidx.collection.LruCache - -class DeferredLruCache(maxSize: Int) : LruCache>(maxSize) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt new file mode 100644 index 000000000..34d46dfca --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.core.cache + +import androidx.collection.LruCache +import java.util.concurrent.TimeUnit + +class ExpiringLruCache( + val maxSize: Int, + private val lifetime: Long, + private val timeUnit: TimeUnit, +) { + + private val cache = LruCache>(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) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringValue.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringValue.kt new file mode 100644 index 000000000..2d561bb0c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/ExpiringValue.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.core.cache + +import android.os.SystemClock +import java.util.concurrent.TimeUnit + +class ExpiringValue( + 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 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt index ffa9a904e..722b06d41 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource +import java.util.concurrent.TimeUnit class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { @@ -13,8 +14,8 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall application.registerComponentCallbacks(this) } - private val detailsCache = DeferredLruCache(4) - private val pagesCache = DeferredLruCache>(4) + private val detailsCache = ExpiringLruCache>(4, 5, TimeUnit.MINUTES) + private val pagesCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) override val isCachingEnabled: Boolean = true @@ -23,7 +24,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall } override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) { - detailsCache.put(ContentCache.Key(source, url), details) + detailsCache[ContentCache.Key(source, url)] = details } override suspend fun getPages(source: MangaSource, url: String): List? { @@ -31,7 +32,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall } override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) { - pagesCache.put(ContentCache.Key(source, url), pages) + pagesCache[ContentCache.Key(source, url)] = pages } override fun onConfigurationChanged(newConfig: Configuration) = Unit @@ -43,17 +44,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall trimCache(pagesCache, level) } - private fun trimCache(cache: DeferredLruCache<*>, level: Int) { + private fun trimCache(cache: ExpiringLruCache<*>, level: Int) { when (level) { ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, ComponentCallbacks2.TRIM_MEMORY_COMPLETE, - ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll() + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear() ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) - else -> cache.trimToSize(cache.maxSize() / 2) + else -> cache.trimToSize(cache.maxSize / 2) } } } From 8323d399ff89e579a9799d3efcdf6bd1a66e0ff5 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 19 May 2023 15:31:14 +0300 Subject: [PATCH 3/5] Fix focus changes on sync authorization screen --- .../main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt index cf24fb7b9..b4bf3a2a6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt @@ -84,12 +84,14 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi binding.groupLogin.isVisible = false binding.groupPassword.isVisible = true pageBackCallback.update() + binding.editPassword.requestFocus() } R.id.button_back -> { binding.groupPassword.isVisible = false binding.groupLogin.isVisible = true pageBackCallback.update() + binding.editEmail.requestFocus() } R.id.button_done -> { @@ -200,6 +202,7 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi override fun handleOnBackPressed() { binding.groupLogin.isVisible = true binding.groupPassword.isVisible = false + binding.editEmail.requestFocus() update() } From 08e5c148fd0a8fce8fd0ed4474982ef196bd7a6d Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 19 May 2023 14:52:07 +0300 Subject: [PATCH 4/5] Limit cache max-age and action to clear cache manually --- .../org/koitharu/kotatsu/core/AppModule.kt | 11 +++++- .../core/network/CacheLimitInterceptor.kt | 26 +++++++++++++ .../kotatsu/core/network/CommonHeaders.kt | 1 + .../kotatsu/core/prefs/AppSettings.kt | 1 + .../settings/HistorySettingsFragment.kt | 38 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_history.xml | 6 +++ 7 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt index 6797915b1..97542c58d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import okhttp3.Cache import okhttp3.CookieJar import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig @@ -88,14 +89,19 @@ interface AppModule { @Provides @Singleton - fun provideOkHttpClient( + fun provideHttpCache( localStorageManager: LocalStorageManager, + ): Cache = localStorageManager.createHttpCache() + + @Provides + @Singleton + fun provideOkHttpClient( + cache: Cache, commonHeadersInterceptor: CommonHeadersInterceptor, mirrorSwitchInterceptor: MirrorSwitchInterceptor, cookieJar: CookieJar, settings: AppSettings, ): OkHttpClient { - val cache = localStorageManager.createHttpCache() return OkHttpClient.Builder().apply { connectTimeout(20, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) @@ -106,6 +112,7 @@ interface AppModule { bypassSSLErrors() } cache(cache) + addNetworkInterceptor(CacheLimitInterceptor()) addInterceptor(GZipInterceptor()) addInterceptor(commonHeadersInterceptor) addInterceptor(CloudFlareInterceptor()) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt new file mode 100644 index 000000000..52710c57b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt @@ -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() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index 943e08f2e..f8976acd6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -13,6 +13,7 @@ object CommonHeaders { const val CONTENT_ENCODING = "Content-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding" const val AUTHORIZATION = "Authorization" + const val CACHE_CONTROL = "Cache-Control" val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 6dab7e362..dc13b7abb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -345,6 +345,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_TRAFFIC_WARNING = "traffic_warning" 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_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index c0b53620a..82f6bc441 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -8,7 +8,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import okhttp3.Cache import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar @@ -39,6 +42,9 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach @Inject lateinit var cookieJar: MutableCookieJar + @Inject + lateinit var cache: Cache + @Inject lateinit var shortcutsUpdater: ShortcutsUpdater @@ -52,6 +58,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES) findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS) + findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindSummaryToHttpCacheSize() findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> viewLifecycleScope.launch { lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED) @@ -90,6 +97,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach true } + AppSettings.KEY_HTTP_CACHE_CLEAR -> { + clearHttpCache() + true + } + AppSettings.KEY_UPDATES_FEED_CLEAR -> { viewLifecycleScope.launch { trackerRepo.clearLogs() @@ -131,6 +143,32 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach 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(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) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.clear_search_history) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6521d51d0..c92e6527d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -417,4 +417,5 @@ Translations WebView not available: check if WebView provider is installed Invalid value + Clear network cache diff --git a/app/src/main/res/xml/pref_history.xml b/app/src/main/res/xml/pref_history.xml index a7304418c..de6088bc8 100644 --- a/app/src/main/res/xml/pref_history.xml +++ b/app/src/main/res/xml/pref_history.xml @@ -39,6 +39,12 @@ android:summary="@string/computing_" android:title="@string/clear_pages_cache" /> + + Date: Tue, 23 May 2023 13:05:37 +0300 Subject: [PATCH 5/5] Update parsers --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index aa5621fc1..24a9609d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 545 - versionName '5.1.1' + versionCode 546 + versionName '5.1.2' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -78,7 +78,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:cae7073f87') { + implementation('com.github.KotatsuApp:kotatsu-parsers:ebcc6391d6') { exclude group: 'org.json', module: 'json' }