From 1493aa39a38e48d8314b605cdbe7e6dbefeaec92 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 4 Jan 2023 15:59:31 +0200 Subject: [PATCH] CookieJar implementation for non-WebView environment --- .../browser/cloudflare/CloudFlareClient.kt | 6 +- .../browser/cloudflare/CloudFlareDialog.kt | 6 +- .../org/koitharu/kotatsu/core/AppModule.kt | 17 +++- .../network/{ => cookies}/AndroidCookieJar.kt | 17 ++-- .../core/network/cookies/CookieWrapper.kt | 84 +++++++++++++++++ .../core/network/cookies/MutableCookieJar.kt | 17 ++++ .../network/cookies/PreferencesCookieJar.kt | 89 +++++++++++++++++++ .../core/parser/MangaLoaderContextImpl.kt | 14 +-- .../settings/HistorySettingsFragment.kt | 4 +- 9 files changed, 229 insertions(+), 25 deletions(-) rename app/src/main/java/org/koitharu/kotatsu/core/network/{ => cookies}/AndroidCookieJar.kt (73%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index 264134e52..c6ad65f5f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -4,12 +4,12 @@ import android.graphics.Bitmap import android.webkit.WebView import android.webkit.WebViewClient import okhttp3.HttpUrl.Companion.toHttpUrl -import org.koitharu.kotatsu.core.network.AndroidCookieJar +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar private const val CF_CLEARANCE = "cf_clearance" class CloudFlareClient( - private val cookieJar: AndroidCookieJar, + private val cookieJar: MutableCookieJar, private val callback: CloudFlareCallback, private val targetUrl: String, ) : WebViewClient() { @@ -42,4 +42,4 @@ class CloudFlareClient( return cookieJar.loadForRequest(targetUrl.toHttpUrl()) .find { it.name == CF_CLEARANCE }?.value } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt index dc18898b3..c2359ad91 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt @@ -12,13 +12,13 @@ import androidx.core.view.isInvisible import androidx.fragment.app.setFragmentResult import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import org.koitharu.kotatsu.base.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.UserAgentInterceptor +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding import org.koitharu.kotatsu.utils.ext.stringArgument import org.koitharu.kotatsu.utils.ext.withArgs +import javax.inject.Inject @AndroidEntryPoint class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback { @@ -27,7 +27,7 @@ class CloudFlareDialog : AlertDialogFragment(), Cloud private val pendingResult = Bundle(1) @Inject - lateinit var cookieJar: AndroidCookieJar + lateinit var cookieJar: MutableCookieJar override fun onInflateView( inflater: LayoutInflater, 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 b5cf78270..576e5f5a6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/AppModule.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import android.provider.SearchRecentSuggestions import android.text.Html +import android.util.AndroidRuntimeException import androidx.collection.arraySetOf import androidx.room.InvalidationTracker import coil.ComponentRegistry @@ -25,6 +26,9 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.network.* +import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl @@ -52,7 +56,7 @@ import javax.inject.Singleton interface AppModule { @Binds - fun bindCookieJar(androidCookieJar: AndroidCookieJar): CookieJar + fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar @Binds fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext @@ -62,6 +66,17 @@ interface AppModule { companion object { + @Provides + @Singleton + fun provideCookieJar( + @ApplicationContext context: Context + ): MutableCookieJar = try { + AndroidCookieJar() + } catch (e: AndroidRuntimeException) { + // WebView is not available + PreferencesCookieJar(context) + } + @Provides @Singleton fun provideOkHttpClient( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt similarity index 73% rename from app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt index bb221d743..5b0c3d822 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt @@ -1,19 +1,17 @@ -package org.koitharu.kotatsu.core.network +package org.koitharu.kotatsu.core.network.cookies import android.webkit.CookieManager -import javax.inject.Inject -import javax.inject.Singleton +import androidx.annotation.WorkerThread +import okhttp3.Cookie +import okhttp3.HttpUrl import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -@Singleton -class AndroidCookieJar @Inject constructor() : CookieJar { +class AndroidCookieJar : MutableCookieJar { private val cookieManager = CookieManager.getInstance() + @WorkerThread override fun loadForRequest(url: HttpUrl): List { val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() return rawCookie.split(';').mapNotNull { @@ -21,6 +19,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar { } } + @WorkerThread override fun saveFromResponse(url: HttpUrl, cookies: List) { if (cookies.isEmpty()) { return @@ -31,7 +30,7 @@ class AndroidCookieJar @Inject constructor() : CookieJar { } } - suspend fun clear() = suspendCoroutine { continuation -> + override suspend fun clear() = suspendCoroutine { continuation -> cookieManager.removeAllCookies(continuation::resume) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt new file mode 100644 index 000000000..6254d720b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt @@ -0,0 +1,84 @@ +package org.koitharu.kotatsu.core.network.cookies + +import android.util.Base64 +import okhttp3.Cookie +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + + +class CookieWrapper( + val cookie: Cookie, +) { + + constructor(encodedString: String) : this( + ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use { + val name = it.readUTF() + val value = it.readUTF() + val expiresAt = it.readLong() + val domain = it.readUTF() + val path = it.readUTF() + val secure = it.readBoolean() + val httpOnly = it.readBoolean() + val persistent = it.readBoolean() + val hostOnly = it.readBoolean() + Cookie.Builder().also { c -> + c.name(name) + c.value(value) + if (persistent) { + c.expiresAt(expiresAt) + } + if (hostOnly) { + c.hostOnlyDomain(domain) + } else { + c.domain(domain) + } + c.path(path) + if (secure) { + c.secure() + } + if (httpOnly) { + c.httpOnly() + } + }.build() + }, + ) + + fun encode(): String { + val output = ByteArrayOutputStream() + ObjectOutputStream(output).use { + it.writeUTF(cookie.name) + it.writeUTF(cookie.value) + it.writeLong(cookie.expiresAt) + it.writeUTF(cookie.domain) + it.writeUTF(cookie.path) + it.writeBoolean(cookie.secure) + it.writeBoolean(cookie.httpOnly) + it.writeBoolean(cookie.persistent) + it.writeBoolean(cookie.hostOnly) + } + return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) + } + + fun isExpired() = cookie.expiresAt < System.currentTimeMillis() + + fun key(): String { + return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CookieWrapper + + if (cookie != other.cookie) return false + + return true + } + + override fun hashCode(): Int { + return cookie.hashCode() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt new file mode 100644 index 000000000..9059e5a6f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.core.network.cookies + +import androidx.annotation.WorkerThread +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +interface MutableCookieJar : CookieJar { + + @WorkerThread + override fun loadForRequest(url: HttpUrl): List + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) + + suspend fun clear(): Boolean +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt new file mode 100644 index 000000000..cce51f827 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt @@ -0,0 +1,89 @@ +package org.koitharu.kotatsu.core.network.cookies + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.collection.ArrayMap +import androidx.core.content.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Cookie +import okhttp3.HttpUrl +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +private const val PREFS_NAME = "cookies" + +class PreferencesCookieJar( + context: Context, +) : MutableCookieJar { + + private val cache = ArrayMap() + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private var isLoaded = false + + @WorkerThread + override fun loadForRequest(url: HttpUrl): List { + loadPersistent() + val expired = HashSet() + val result = ArrayList() + for ((key, cookie) in cache) { + if (cookie.isExpired()) { + expired += key + } else if (cookie.cookie.matches(url)) { + result += cookie.cookie + } + } + if (expired.isNotEmpty()) { + cache.removeAll(expired) + removePersistent(expired) + } + return result + } + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val wrapped = cookies.map { CookieWrapper(it) } + prefs.edit(commit = true) { + for (cookie in wrapped) { + val key = cookie.key() + cache[key] = cookie + if (cookie.cookie.persistent) { + putString(key, cookie.encode()) + } + } + } + } + + override suspend fun clear(): Boolean { + cache.clear() + withContext(Dispatchers.IO) { + prefs.edit(commit = true) { clear() } + } + return true + } + + @Synchronized + private fun loadPersistent() { + if (!isLoaded) { + val map = prefs.all + cache.ensureCapacity(map.size) + for ((k, v) in map) { + val cookie = try { + CookieWrapper(v as String) + } catch (e: Exception) { + e.printStackTraceDebug() + continue + } + cache[k] = cookie + } + isLoaded = true + } + } + + private fun removePersistent(keys: Collection) { + prefs.edit(commit = true) { + for (key in keys) { + remove(key) + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index 1d4d9784f..4bf8e8b7b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -6,25 +6,25 @@ import android.util.Base64 import android.webkit.WebView import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.* -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import org.koitharu.kotatsu.core.network.AndroidCookieJar +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.toList +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine @Singleton class MangaLoaderContextImpl @Inject constructor( override val httpClient: OkHttpClient, - override val cookieJar: AndroidCookieJar, + override val cookieJar: MutableCookieJar, @ApplicationContext private val androidContext: Context, ) : MangaLoaderContext() { 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 595d47caa..a51e26271 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.network.AndroidCookieJar +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.CacheDir @@ -41,7 +41,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach lateinit var shikimoriRepository: ShikimoriRepository @Inject - lateinit var cookieJar: AndroidCookieJar + lateinit var cookieJar: MutableCookieJar @Inject lateinit var shortcutsUpdater: ShortcutsUpdater