diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 10faa7ae2..2a167d459 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -102,6 +102,10 @@ android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:windowSoftInputMode="adjustResize" /> + (), BrowserCallback override fun onDestroy() { super.onDestroy() + viewBinding.webView.stopLoading() viewBinding.webView.destroy() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt new file mode 100644 index 000000000..51d29fc9f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -0,0 +1,175 @@ +package org.koitharu.kotatsu.browser.cloudflare + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.webkit.CookieManager +import android.webkit.WebSettings +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.graphics.Insets +import androidx.core.net.toUri +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import dagger.hilt.android.AndroidEntryPoint +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.WebViewBackPressedCallback +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.TaggedActivityResult +import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability +import org.koitharu.kotatsu.databinding.ActivityBrowserBinding +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class CloudFlareActivity : BaseActivity(), CloudFlareCallback { + + private var pendingResult = RESULT_CANCELED + + @Inject + lateinit var cookieJar: MutableCookieJar + + private var onBackPressedCallback: WebViewBackPressedCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { + return + } + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) + } + val url = intent?.dataString.orEmpty() + with(viewBinding.webView.settings) { + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + domStorageEnabled = true + databaseEnabled = true + userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback + } + viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url) + onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { + onBackPressedDispatcher.addCallback(it) + } + CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) + if (savedInstanceState != null) { + return + } + if (url.isEmpty()) { + finishAfterTransition() + } else { + onTitleChanged(getString(R.string.loading_), url) + viewBinding.webView.loadUrl(url) + } + } + + override fun onDestroy() { + viewBinding.webView.run { + stopLoading() + destroy() + } + super.onDestroy() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + viewBinding.webView.saveState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + viewBinding.webView.restoreState(savedInstanceState) + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.appbar.updatePadding( + top = insets.top, + ) + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + viewBinding.webView.stopLoading() + finishAfterTransition() + true + } + + else -> super.onOptionsItemSelected(item) + } + + override fun onResume() { + super.onResume() + viewBinding.webView.onResume() + } + + override fun onPause() { + viewBinding.webView.onPause() + super.onPause() + } + + override fun finish() { + setResult(pendingResult) + super.finish() + } + + override fun onPageLoaded() { + viewBinding.progressBar.isInvisible = true + } + + override fun onCheckPassed() { + pendingResult = RESULT_OK + finishAfterTransition() + } + + override fun onLoadingStateChanged(isLoading: Boolean) { + viewBinding.progressBar.isVisible = isLoading + } + + override fun onHistoryChanged() { + onBackPressedCallback?.onHistoryChanged() + } + + override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { + setTitle(title) + supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle + } + + class Contract : ActivityResultContract, TaggedActivityResult>() { + override fun createIntent(context: Context, input: Pair): Intent { + return newIntent(context, input.first, input.second) + } + + override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult { + return TaggedActivityResult(TAG, resultCode) + } + } + + companion object { + + const val TAG = "CloudFlareActivity" + private const val ARG_UA = "ua" + + fun newIntent( + context: Context, + url: String, + headers: Headers?, + ) = Intent(context, CloudFlareActivity::class.java).apply { + data = url.toUri() + headers?.get(CommonHeaders.USER_AGENT)?.let { + putExtra(ARG_UA, it) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt deleted file mode 100644 index 0a1284448..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt +++ /dev/null @@ -1,123 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.webkit.CookieManager -import android.webkit.WebSettings -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isInvisible -import androidx.fragment.app.setFragmentResult -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import okhttp3.Headers -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding -import javax.inject.Inject - -@AndroidEntryPoint -class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback { - - private lateinit var url: String - private val pendingResult = Bundle(1) - - @Inject - lateinit var cookieJar: MutableCookieJar - - private var onBackPressedCallback: WebViewBackPressedCallback? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - url = requireArguments().getString(ARG_URL).orEmpty() - } - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentCloudflareBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.webView.settings) { - javaScriptEnabled = true - cacheMode = WebSettings.LOAD_DEFAULT - domStorageEnabled = true - databaseEnabled = true - userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome - } - binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url) - CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true) - if (url.isEmpty()) { - dismissAllowingStateLoss() - } else { - binding.webView.loadUrl(url) - } - } - - override fun onDestroyView() { - requireViewBinding().webView.stopLoading() - requireViewBinding().webView.destroy() - onBackPressedCallback = null - super.onDestroyView() - } - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null) - } - - override fun onDialogCreated(dialog: AlertDialog) { - super.onDialogCreated(dialog) - onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also { - dialog.onBackPressedDispatcher.addCallback(it) - } - } - - override fun onResume() { - super.onResume() - requireViewBinding().webView.onResume() - } - - override fun onPause() { - requireViewBinding().webView.onPause() - super.onPause() - } - - override fun onDismiss(dialog: DialogInterface) { - setFragmentResult(TAG, pendingResult) - super.onDismiss(dialog) - } - - override fun onPageLoaded() { - viewBinding?.progressBar?.isInvisible = true - } - - override fun onCheckPassed() { - pendingResult.putBoolean(EXTRA_RESULT, true) - dismissAllowingStateLoss() - } - - override fun onHistoryChanged() { - onBackPressedCallback?.onHistoryChanged() - } - - companion object { - - const val TAG = "CloudFlareDialog" - const val EXTRA_RESULT = "result" - private const val ARG_URL = "url" - private const val ARG_UA = "ua" - - fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) { - putString(ARG_URL, url) - headers?.get(CommonHeaders.USER_AGENT)?.let { - putString(ARG_UA, it) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index b5e267dcb..9407ab6e9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -6,15 +6,13 @@ import androidx.annotation.StringRes import androidx.collection.ArrayMap import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Headers import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity -import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.isSuccess import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.MangaSource @@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class ExceptionResolver private constructor( - private val activity: FragmentActivity?, - private val fragment: Fragment?, -) : ActivityResultCallback { +class ExceptionResolver : ActivityResultCallback { private val continuations = ArrayMap>(1) - private lateinit var sourceAuthContract: ActivityResultLauncher + private val activity: FragmentActivity? + private val fragment: Fragment? + private val sourceAuthContract: ActivityResultLauncher + private val cloudflareContract: ActivityResultLauncher> - constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) { + constructor(activity: FragmentActivity) { + this.activity = activity + fragment = null sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this) + cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this) } - constructor(fragment: Fragment) : this(activity = null, fragment = fragment) { + constructor(fragment: Fragment) { + this.fragment = fragment + activity = null sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) + cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this) } override fun onActivityResult(result: TaggedActivityResult) { @@ -58,22 +62,9 @@ class ExceptionResolver private constructor( else -> false } - private suspend fun resolveCF(url: String, headers: Headers): Boolean { - val dialog = CloudFlareDialog.newInstance(url, headers) - val fm = getFragmentManager() - return suspendCancellableCoroutine { cont -> - fm.clearFragmentResult(CloudFlareDialog.TAG) - continuations[CloudFlareDialog.TAG] = cont - fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result -> - continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT)) - } - dialog.show(fm, CloudFlareDialog.TAG) - cont.invokeOnCancellation { - continuations.remove(CloudFlareDialog.TAG, cont) - fm.clearFragmentResultListener(CloudFlareDialog.TAG) - dialog.dismissAllowingStateLoss() - } - } + private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont -> + continuations[CloudFlareActivity.TAG] = cont + cloudflareContract.launch(url to headers) } private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt index 40c091d4d..6809043ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt @@ -27,6 +27,7 @@ abstract class AlertDialogFragment : DialogFragment() { .setView(binding.root) .run(::onBuildDialog) .create() + .also(::onDialogCreated) } final override fun onCreateView( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt index 8fba053eb..c55aaa121 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt @@ -5,7 +5,8 @@ import android.app.Activity class TaggedActivityResult( val tag: String, val result: Int, -) +) { -val TaggedActivityResult.isSuccess: Boolean - get() = this.result == Activity.RESULT_OK + val isSuccess: Boolean + get() = result == Activity.RESULT_OK +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index bc43c656e..e418d1d1b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -5,7 +5,7 @@ import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.util.UUID -inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { +inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { return if (this.isNullOrEmpty()) defaultValue() else this } diff --git a/app/src/main/res/layout/fragment_cloudflare.xml b/app/src/main/res/layout/fragment_cloudflare.xml deleted file mode 100644 index 91a115312..000000000 --- a/app/src/main/res/layout/fragment_cloudflare.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - \ No newline at end of file