Apply proxy settings to WebView

This commit is contained in:
Koitharu
2025-03-08 18:36:59 +02:00
parent 2c8476cabd
commit b3028258ca
13 changed files with 318 additions and 321 deletions

View File

@@ -0,0 +1,82 @@
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.View
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
@Inject
lateinit var proxyProvider: ProxyProvider
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
}

View File

@@ -3,12 +3,10 @@ package org.koitharu.kotatsu.browser
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import androidx.lifecycle.lifecycleScope
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
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.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
@@ -16,26 +14,20 @@ import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class BrowserActivity : BaseBrowserActivity() {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
@Inject @Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { 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)
@@ -44,42 +36,27 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) lifecycleScope.launch {
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) try {
onBackPressedDispatcher.addCallback(onBackPressedCallback) proxyProvider.applyWebViewConfig()
if (savedInstanceState != null) { } catch (e: Exception) {
return e.printStackTraceDebug()
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
}
}
} }
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
}
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -104,35 +81,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
} }

View File

@@ -2,9 +2,13 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import androidx.webkit.WebViewClientCompat
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() { open class BrowserClient(
private val proxyProvider: ProxyProvider,
private val callback: BrowserCallback
) : WebViewClientCompat() {
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)
@@ -16,7 +20,7 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
callback.onLoadingStateChanged(isLoading = true) callback.onLoadingStateChanged(isLoading = true)
} }
override fun onPageCommitVisible(view: WebView, url: String?) { override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url) callback.onTitleChanged(view.title.orEmpty(), url)
} }

View File

@@ -5,13 +5,10 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -20,21 +17,19 @@ import kotlinx.coroutines.yield
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback { class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED private var pendingResult = RESULT_CANCELED
@@ -42,13 +37,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
private lateinit var cfClient: CloudFlareClient private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { 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)
@@ -58,45 +49,20 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
finishAfterTransition() finishAfterTransition()
return return
} }
cfClient = CloudFlareClient(cookieJar, this, url) cfClient = CloudFlareClient(proxyProvider, cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT)) viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.webViewClient = cfClient viewBinding.webView.webViewClient = cfClient
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { lifecycleScope.launch {
onBackPressedDispatcher.addCallback(it) try {
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
} }
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
}
override fun onDestroy() {
runCatching {
viewBinding.webView
}.onSuccess {
it.stopLoading()
it.destroy()
}
super.onDestroy()
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@@ -119,21 +85,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() { override fun finish() {
setResult(pendingResult) setResult(pendingResult)
super.finish() super.finish()
} }
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onPageLoaded() { override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true viewBinding.progressBar.isInvisible = true
} }
@@ -151,14 +109,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
finishAfterTransition() finishAfterTransition()
} }
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title) setTitle(title)
supportActionBar?.subtitle = supportActionBar?.subtitle =

View File

@@ -4,8 +4,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback { interface CloudFlareCallback : BrowserCallback {
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded() fun onPageLoaded()

View File

@@ -4,15 +4,17 @@ import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val LOOP_COUNTER = 3 private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
proxyProvider: ProxyProvider,
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String, private val targetUrl: String,
) : BrowserClient(callback) { ) : BrowserClient(proxyProvider, callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
private var counter = 0 private var counter = 0
@@ -22,7 +24,7 @@ class CloudFlareClient(
checkClearance() checkClearance()
} }
override fun onPageCommitVisible(view: WebView, url: String?) { override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onPageLoaded() callback.onPageLoaded()
} }

View File

@@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.network
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI
class AppProxySelector(
private val settings: AppSettings,
) : ProxySelector() {
init {
setDefault(this)
}
private var cachedProxy: Proxy? = null
override fun select(uri: URI?): List<Proxy> {
return listOf(getProxy())
}
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
ioe?.printStackTraceDebug()
}
private fun getProxy(): Proxy {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {
val addr = it.address() as? InetSocketAddress
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
return it
}
}
val proxy = Proxy(type, InetSocketAddress(address, port))
cachedProxy = proxy
return proxy
}
}

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -62,14 +63,15 @@ interface NetworkModule {
cache: Cache, cache: Cache,
cookieJar: CookieJar, cookieJar: CookieJar,
settings: AppSettings, settings: AppSettings,
proxyProvider: ProxyProvider,
): OkHttpClient = OkHttpClient.Builder().apply { ): OkHttpClient = OkHttpClient.Builder().apply {
assertNotInMainThread() assertNotInMainThread()
connectTimeout(20, TimeUnit.SECONDS) connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar) cookieJar(cookieJar)
proxySelector(AppProxySelector(settings)) proxySelector(proxyProvider.selector)
proxyAuthenticator(ProxyAuthenticator(settings)) proxyAuthenticator(proxyProvider.authenticator)
dns(DoHManager(cache, settings)) dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
disableCertificateVerification() disableCertificateVerification()

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.net.PasswordAuthentication
import java.net.Proxy
class ProxyAuthenticator(
private val settings: AppSettings,
) : Authenticator, java.net.Authenticator() {
init {
setDefault(this)
}
override fun authenticate(route: Route?, response: Response): Request? {
if (!isProxyEnabled()) {
return null
}
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
val credential = Credentials.basic(login, password)
return response.request.newBuilder()
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
.build()
}
override fun getPasswordAuthentication(): PasswordAuthentication? {
if (!isProxyEnabled()) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
return PasswordAuthentication(login, password.toCharArray())
}
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
}

View File

@@ -0,0 +1,150 @@
package org.koitharu.kotatsu.core.network.proxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetSocketAddress
import java.net.PasswordAuthentication
import java.net.Proxy
import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import java.net.Authenticator as JavaAuthenticator
@Singleton
class ProxyProvider @Inject constructor(
private val settings: AppSettings,
) {
private var cachedProxy: Proxy? = null
val selector = object : ProxySelector() {
override fun select(uri: URI?): List<Proxy> {
return listOf(getProxy())
}
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
ioe?.printStackTraceDebug()
}
}
val authenticator = ProxyAuthenticator()
init {
ProxySelector.setDefault(selector)
JavaAuthenticator.setDefault(authenticator)
}
suspend fun applyWebViewConfig() {
val isProxyEnabled = isProxyEnabled()
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
if (isProxyEnabled) {
throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize
}
} else {
val controller = ProxyController.getInstance()
if (settings.proxyType == Proxy.Type.DIRECT) {
suspendCoroutine { cont ->
controller.clearProxyOverride(
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
) {
cont.resume(Unit)
}
}
} else {
val url = buildString {
when (settings.proxyType) {
Proxy.Type.DIRECT -> Unit
Proxy.Type.HTTP -> append("http")
Proxy.Type.SOCKS -> append("socks")
}
append("://")
append(settings.proxyAddress)
append(':')
append(settings.proxyPort)
}
if (settings.proxyType == Proxy.Type.SOCKS) {
System.setProperty("java.net.socks.username", settings.proxyLogin);
System.setProperty("java.net.socks.password", settings.proxyPassword);
}
val proxyConfig = ProxyConfig.Builder()
.addProxyRule(url)
.build()
suspendCoroutine { cont ->
controller.setProxyOverride(
proxyConfig,
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
) {
cont.resume(Unit)
}
}
}
}
}
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
private fun getProxy(): Proxy {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {
val addr = it.address() as? InetSocketAddress
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
return it
}
}
val proxy = Proxy(type, InetSocketAddress(address, port))
cachedProxy = proxy
return proxy
}
inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() {
override fun authenticate(route: Route?, response: Response): Request? {
if (!isProxyEnabled()) {
return null
}
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
val credential = Credentials.basic(login, password)
return response.request.newBuilder()
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
.build()
}
public override fun getPasswordAuthentication(): PasswordAuthentication? {
if (!isProxyEnabled()) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
return PasswordAuthentication(login, password.toCharArray())
}
}
}

View File

@@ -43,6 +43,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_proxy) addPreferencesFromResource(R.xml.pref_proxy)
@Suppress("UsePropertyAccessSyntax")
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener( findPreference<EditTextPreference>(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener(
EditTextBindListener( EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
@@ -50,6 +51,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
validator = DomainValidator(), validator = DomainValidator(),
), ),
) )
@Suppress("UsePropertyAccessSyntax")
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener( findPreference<EditTextPreference>(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener(
EditTextBindListener( EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_NUMBER, inputType = EditorInfo.TYPE_CLASS_NUMBER,
@@ -58,6 +60,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
), ),
) )
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_PASSWORD)?.let { pref -> findPreference<EditTextPreference>(AppSettings.KEY_PROXY_PASSWORD)?.let { pref ->
@Suppress("UsePropertyAccessSyntax")
pref.setOnBindEditTextListener( pref.setOnBindEditTextListener(
EditTextBindListener( EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD, inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD,

View File

@@ -1,30 +1,26 @@
package org.koitharu.kotatsu.settings.sources.auth package org.koitharu.kotatsu.settings.sources.auth
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope
import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.browser.BrowserCallback import org.koitharu.kotatsu.browser.BrowserCallback
import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.browser.ProgressChromeClient
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
@@ -34,15 +30,13 @@ import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
@Inject @Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory lateinit var mangaRepositoryFactory: MangaRepository.Factory
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
private lateinit var authProvider: MangaParserAuthProvider private lateinit var authProvider: MangaParserAuthProvider
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
@@ -68,43 +62,22 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT]) viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT])
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) lifecycleScope.launch {
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) try {
onBackPressedDispatcher.addCallback(onBackPressedCallback) proxyProvider.applyWebViewConfig()
if (savedInstanceState != null) { } catch (e: Exception) {
return Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
val url = authProvider.authUrl
onTitleChanged(
source.title,
getString(R.string.loading_),
)
viewBinding.webView.loadUrl(url)
}
} }
val url = authProvider.authUrl
onTitleChanged(
source.title,
getString(R.string.loading_),
)
viewBinding.webView.loadUrl(url)
}
override fun onDestroy() {
super.onDestroy()
viewBinding.webView.destroy()
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@@ -118,18 +91,8 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading super.onLoadingStateChanged(isLoading)
if (!isLoading && authProvider.isAuthorized) { if (!isLoading && authProvider.isAuthorized) {
Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show()
setResult(RESULT_OK) setResult(RESULT_OK)
@@ -137,15 +100,6 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
} }
} }
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
class Contract : ActivityResultContract<MangaSource, Boolean>() { class Contract : ActivityResultContract<MangaSource, Boolean>() {
override fun createIntent(context: Context, input: MangaSource): Intent { override fun createIntent(context: Context, input: MangaSource): Intent {
return AppRouter.sourceAuthIntent(context, input) return AppRouter.sourceAuthIntent(context, input)

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -33,15 +34,16 @@ class JsonSerializerTest {
val entity = MangaEntity( val entity = MangaEntity(
id = 231, id = 231,
title = "Lorem Ipsum", title = "Lorem Ipsum",
altTitle = "Lorem Ispum 2", altTitles = "Lorem Ispum 2",
url = "erw", url = "erw",
publicUrl = "hthth", publicUrl = "hthth",
rating = 0.78f, rating = 0.78f,
isNsfw = true, isNsfw = true,
contentRating = ContentRating.ADULT.name,
coverUrl = "5345", coverUrl = "5345",
largeCoverUrl = null, largeCoverUrl = null,
state = MangaState.FINISHED.name, state = MangaState.FINISHED.name,
author = "RERE", authors = "RERE",
source = MangaParserSource.DUMMY.name, source = MangaParserSource.DUMMY.name,
) )
val json = JsonSerializer(entity).toJson() val json = JsonSerializer(entity).toJson()