From d3c85eccfe1ea5b33ba4badac59de674424b4c3b Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Mon, 2 Feb 2026 20:56:10 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8D=8A=E5=B1=8F=E6=AE=B5=E8=AF=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/widget/dialog/BottomWebViewDialog.kt | 804 ++++++++++++++---- 1 file changed, 630 insertions(+), 174 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt index a4da94930..456dbad2c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt @@ -1,36 +1,54 @@ package io.legado.app.ui.widget.dialog import android.annotation.SuppressLint +import android.content.Context import android.content.pm.ActivityInfo import android.graphics.Bitmap import android.net.Uri import android.net.http.SslError +import android.os.Build import android.os.Bundle +import android.util.Base64 +import android.util.TypedValue +import android.view.Gravity import android.view.KeyEvent import android.view.View import android.view.ViewGroup +import android.view.WindowManager +import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler +import android.webkit.URLUtil import android.webkit.WebChromeClient -import android.webkit.WebChromeClient.CustomViewCallback import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.annotation.Keep import androidx.appcompat.app.AppCompatActivity import androidx.core.net.toUri -import androidx.core.view.isNotEmpty +import androidx.core.view.size import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment import io.legado.app.constant.AppConst +import io.legado.app.constant.AppConst.imagePathKey import io.legado.app.constant.AppLog import io.legado.app.data.appDb import io.legado.app.data.entities.BaseSource import io.legado.app.databinding.DialogWebViewBinding +import io.legado.app.exception.NoStackTraceException import io.legado.app.help.WebCacheManager import io.legado.app.help.config.AppConfig +import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.http.newCallResponse +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text import io.legado.app.help.webView.WebJsExtensions import io.legado.app.help.webView.WebJsExtensions.Companion.JS_INJECTION import io.legado.app.help.webView.WebJsExtensions.Companion.basicJs @@ -38,22 +56,37 @@ import io.legado.app.help.webView.WebJsExtensions.Companion.nameBasic import io.legado.app.help.webView.WebJsExtensions.Companion.nameCache import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource -import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.lib.dialogs.selector import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.ui.association.OnLineImportActivity -import io.legado.app.utils.get +import io.legado.app.ui.file.HandleFileContract +import io.legado.app.utils.ACache +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject import io.legado.app.utils.invisible import io.legado.app.utils.longSnackbar import io.legado.app.utils.openUrl +import io.legado.app.utils.setLayout import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible +import io.legado.app.utils.writeBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayInputStream import java.lang.ref.WeakReference +import java.util.Date -class BottomWebViewDialog() : - BaseBottomSheetDialogFragment(R.layout.dialog_web_view) { +class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view), + WebJsExtensions.Callback { + + companion object { + const val DEFAULT_JS_INJECT_URL = "https://legado-inject-js.internal" + } constructor( sourceKey: String, @@ -74,25 +107,45 @@ class BottomWebViewDialog() : } private val binding by viewBinding(DialogWebViewBinding::bind) - private val bottomSheet by lazy { dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet) } - private val behavior by lazy { - bottomSheet?.let { BottomSheetBehavior.from(it) } + bottomSheet?.let { sheet -> + BottomSheetBehavior.from(sheet) + } + } + private val displayMetrics by lazy { resources.displayMetrics } + private val selectImageDir = registerForActivityResult(HandleFileContract()) { + it.uri?.let { uri -> + ACache.get().put(imagePathKey, uri.toString()) + saveImage(it.value, uri) + } } private lateinit var currentWebView: WebView - private var source: BaseSource? = null + private var preloadJs: String? = null private var isFullScreen = false - private var customWebViewCallback: CustomViewCallback? = null + private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null private var originOrientation: Int? = null private var needClearHistory = true + private var isBasicJsInjected = false + + override fun onAttach(context: Context) { + super.onAttach(context) + currentWebView = WebView(context) + initWebViewBaseConfig() + } + + + override fun onStart() { + super.onStart() + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } override fun show(manager: FragmentManager, tag: String?) { - runCatching { + kotlin.runCatching { manager.beginTransaction().remove(this).commit() super.show(manager, tag) }.onFailure { @@ -100,90 +153,329 @@ class BottomWebViewDialog() : } } - override fun onFragmentCreated( - view: View, - savedInstanceState: Bundle? - ) { - if (!AppConfig.isEInkMode) { - view.setBackgroundColor(ThemeStore.backgroundColor()) + private fun setConfig(config: Config, first: Boolean = false) { + behavior?.let { behavior -> + config.state?.let { behavior.state = it } + config.peekHeight?.let { behavior.peekHeight = it } + config.isHideable?.let { behavior.isHideable = it } + config.skipCollapsed?.let { behavior.skipCollapsed = it } + config.setHalfExpandedRatio?.let { behavior.setHalfExpandedRatio(it) } + config.setExpandedOffset?.let { behavior.setExpandedOffset(it) } + config.setFitToContents?.let { behavior.setFitToContents(it) } + config.isDraggable?.let { behavior.isDraggable = it } + config.isDraggableOnNestedScroll?.let { behavior.isDraggableOnNestedScroll = it } + config.significantVelocityThreshold?.let { behavior.significantVelocityThreshold = it } + config.hideFriction?.let { behavior.hideFriction = it } + config.maxWidth?.let { behavior.maxWidth = it } + config.maxHeight?.let { behavior.maxHeight = it } + config.isGestureInsetBottomIgnored?.let { behavior.isGestureInsetBottomIgnored = it } + config.setUpdateImportantForAccessibilityOnSiblings?.let { + behavior.setUpdateImportantForAccessibilityOnSiblings(it) + } } - currentWebView = createWebView() - binding.webViewContainer.addView(currentWebView) - - lifecycleScope.launch { - val args = arguments ?: run { - dismiss(); return@launch - } - - val sourceKey = args.getString("sourceKey") ?: return@launch - val url = args.getString("url") ?: return@launch - var html = args.getString("html") ?: return@launch - - args.getString("preloadJs")?.let { preloadJs -> - html = if (html.contains("")) { - html.replaceFirst( - "", - "" - ) + config.expandedCornersRadius?.let { + val radius = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, it, displayMetrics + ) + bottomSheet?.let { sheet -> + if (radius > 0) { + sheet.backgroundTintList = null + val shapeDrawable = + android.graphics.drawable.GradientDrawable().apply { + cornerRadius = 0f + cornerRadii = floatArrayOf( + radius, radius, + radius, radius, + 0f, 0f, + 0f, 0f + ) + } + sheet.background = shapeDrawable + sheet.clipToOutline = true + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + currentWebView.outlineProvider = + object : android.view.ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: android.graphics.Outline + ) { + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + currentWebView.clipToOutline = true + binding.customWebView.outlineProvider = + object : android.view.ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: android.graphics.Outline + ) { + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + binding.customWebView.clipToOutline = true + } } else { - "$html" + sheet.backgroundTintList = null + sheet.background = null + sheet.clipToOutline = false + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + currentWebView.outlineProvider = null + currentWebView.clipToOutline = false + binding.customWebView.outlineProvider = null + binding.customWebView.clipToOutline = false + } } } - - source = appDb.bookSourceDao.getBookSource(sourceKey) ?: run { - activity?.toastOnUi("no find bookSource") - dismiss() - return@launch - } - - val bookType = args.getInt("bookType", 0) - val analyzeUrl = AnalyzeUrl(url, source = source, coroutineContext = coroutineContext) - - currentWebView.setOnScrollChangeListener { _, _, scrollY, _, _ -> - behavior?.isDraggable = scrollY == 0 - } - - currentWebView.post { - initWebView( - analyzeUrl.url, - html, - analyzeUrl.headerMap, - bookType - ) - currentWebView.clearHistory() - } } - dialog?.setOnKeyListener { _, keyCode, event -> - if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - when { - binding.customWebView.isNotEmpty() -> { - customWebViewCallback?.onCustomViewHidden() - true - } + dialog?.let { dialog -> + config.backgroundDimAmount?.let { amount -> + dialog.window?.setDimAmount(amount) + } + config.shouldDimBackground?.let { shouldDim -> + if (!shouldDim) { + dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + } + config.dismissOnTouchOutside?.let { touchOutside -> + isCancelable = touchOutside + } + config.hardwareAccelerated?.let { hwAccel -> + if (hwAccel) { + dialog.window?.addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) + } + } + } - currentWebView.canGoBack() -> { - currentWebView.goBack() - true - } + currentWebView.let { webView -> + config.webViewInitialScale?.let { scale -> + webView.settings.apply { + loadWithOverviewMode = true + useWideViewPort = true + textZoom = scale + } + } + config.webViewCacheMode?.let { cacheMode -> + webView.settings.cacheMode = cacheMode + } + config.isNestedScrollingEnabled?.let { enabled -> + webView.isNestedScrollingEnabled = enabled + } ?: run { + webView.isNestedScrollingEnabled = true + } + } - else -> { - dismiss() - true + bottomSheet?.let { sheet -> + val params = sheet.layoutParams + var hasChanged = false + config.widthPercentage?.let { percentage -> + if (percentage in 0.0..1.0) { + val width = (displayMetrics.widthPixels * percentage).toInt() + params.width = width + hasChanged = true + } + } + + val dialogHeight = config.dialogHeight ?: if (first) -1 else null + dialogHeight?.let { height -> + params.height = height + hasChanged = true + } + config.heightPercentage?.let { percentage -> + if (percentage in 0.0..1.0) { + val height = (displayMetrics.heightPixels * percentage).toInt() + params.height = height + if (config.peekHeight == null) { + behavior?.peekHeight = height + } + if (config.maxHeight == null) { + behavior?.maxHeight = height + } + hasChanged = true + } + } + if (hasChanged) { + sheet.layoutParams = params + } + } + + config.responsiveBreakpoint?.let { breakpoint -> + val screenWidth = displayMetrics.widthPixels + if (screenWidth < breakpoint) { + behavior?.peekHeight = config.peekHeight ?: 300 + config.widthPercentage?.let { percentage -> + if (percentage > 0.8f) { + val maxWidth = (screenWidth * 0.9).toInt() + behavior?.maxWidth = maxWidth } } - } else false + } else { + behavior?.peekHeight = config.peekHeight ?: 400 + config.widthPercentage?.let { percentage -> + if (percentage < 0.6f) { + bottomSheet?.layoutParams?.width = + (screenWidth * percentage).toInt() + (bottomSheet?.layoutParams as? FrameLayout.LayoutParams)?.gravity = + Gravity.CENTER_HORIZONTAL + } + } + } + } + + val scrollNoDraggable = config.scrollNoDraggable ?: if (first) true else null + scrollNoDraggable?.let { + if (it) { + currentWebView.setOnScrollChangeListener { _, _, scrollY, _, _ -> + behavior?.isDraggable = scrollY <= 0 + } + } else { + currentWebView.setOnScrollChangeListener(null) + } + } + + val longClickSaveImg = config.longClickSaveImg ?: if (first) true else null + longClickSaveImg?.let { + if (it) { + setLongClickSaveImg() + } else { + currentWebView.setOnLongClickListener(null) + } } } - private fun createWebView(): WebView = - WebView(requireContext()).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + private fun setLongClickSaveImg() { + currentWebView.setOnLongClickListener { + val hitTestResult = currentWebView.hitTestResult + if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || + hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE + ) { + hitTestResult.extra?.let { webPic -> + requireContext().selector( + arrayListOf( + SelectItem(getString(R.string.action_save), "save"), + SelectItem(getString(R.string.select_folder), "selectFolder") + ) + ) { _, charSequence, _ -> + when (charSequence.value) { + "save" -> saveImage(webPic) + "selectFolder" -> selectSaveFolder(null) + } + } + return@setOnLongClickListener true + } + } + return@setOnLongClickListener false } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.setBackgroundColor(0) + binding.webViewContainer.addView(currentWebView) + lifecycleScope.launch(IO) { + val args = arguments + if (args == null) { + dismiss() + return@launch + } + val sourceKey = args.getString("sourceKey") ?: return@launch + val url = args.getString("url") ?: return@launch + kotlin.runCatching { + // 初始化配置 + args.getString("config")?.let { json -> + try { + GSON.fromJsonObject(json).getOrThrow().let { config -> + activity?.runOnUiThread { + setConfig(config, true) + } + } + } catch (e: Exception) { + AppLog.put("config err", e) + } + } ?: run { + activity?.runOnUiThread { + bottomSheet?.let { sheet -> + val layoutParams = sheet.layoutParams + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + sheet.layoutParams = layoutParams + } + + currentWebView.overScrollMode = View.OVER_SCROLL_NEVER + currentWebView.setOnScrollChangeListener { _, _, scrollY, _, _ -> + behavior?.isDraggable = (scrollY <= 0) + } + + setLongClickSaveImg() + } + } + val analyzeUrl = + AnalyzeUrl(url, source = source, coroutineContext = coroutineContext) + val html = args.getString("html") ?: analyzeUrl.getStrResponseAwait().body + if (html.isNullOrEmpty()) { + throw NoStackTraceException("html is NullOrEmpty") + } + preloadJs = args.getString("preloadJs") + val spliceHtml = buildSpliceHtml(html) + source = appDb.bookSourceDao.getBookSource(sourceKey) ?: run { + activity?.toastOnUi("no find bookSource") + dismiss() + return@launch + } + val bookType = args.getInt("bookType", 0) + activity?.runOnUiThread { + initWebView(analyzeUrl.url, spliceHtml, analyzeUrl.headerMap, bookType) + if (needClearHistory) { + currentWebView.clearHistory() + needClearHistory = false + } + } + }.onFailure { + activity?.runOnUiThread { + currentWebView.loadDataWithBaseURL( + url, + "加载失败:${it.localizedMessage}", + "text/html", + "utf-8", + url + ) + } + AppLog.put("WebView加载失败", it) + } + } + dialog?.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + if (binding.customWebView.size > 0) { + customWebViewCallback?.onCustomViewHidden() + return@setOnKeyListener true + } + if (currentWebView.canGoBack()) { + currentWebView.goBack() + return@setOnKeyListener true + } + dismiss() + return@setOnKeyListener true + } + false + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebViewBaseConfig() { + currentWebView.settings.apply { + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + domStorageEnabled = true + useWideViewPort = true + loadWithOverviewMode = true + builtInZoomControls = true + javaScriptEnabled = true + displayZoomControls = false + } + currentWebView.isClickable = true + currentWebView.isFocusable = true + currentWebView.isFocusableInTouchMode = true + currentWebView.setOnLongClickListener(null) + } private fun initWebView( url: String, @@ -191,81 +483,145 @@ class BottomWebViewDialog() : headerMap: HashMap, bookType: Int ) { - currentWebView.apply { - webChromeClient = CustomWebChromeClient() - webViewClient = CustomWebViewClient() - - settings.userAgentString = - headerMap.get(AppConst.UA_NAME, true) - - addJavascriptInterface(JSInterface(this@BottomWebViewDialog), nameBasic) - - source?.let { source -> - (activity as? AppCompatActivity)?.let { act -> - addJavascriptInterface( - WebJsExtensions(source, act, this, bookType), - nameJava - ) - } - addJavascriptInterface(source, nameSource) - addJavascriptInterface(WebCacheManager, nameCache) + currentWebView.webViewClient = CustomWebViewClient() + currentWebView.webChromeClient = CustomWebChromeClient() + headerMap[AppConst.UA_NAME]?.let { + currentWebView.settings.userAgentString = it + } + currentWebView.addJavascriptInterface(JSInterface(this), nameBasic) + source?.let { source -> + (activity as? AppCompatActivity)?.let { currentActivity -> + val webJsExtensions = WebJsExtensions( + source, currentActivity, currentWebView, bookType, callback = this + ) + currentWebView.addJavascriptInterface(webJsExtensions, nameJava) } + currentWebView.addJavascriptInterface(source, nameSource) + currentWebView.addJavascriptInterface(WebCacheManager, nameCache) + } + currentWebView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url) + } - loadDataWithBaseURL(url, html, "text/html", "utf-8", url) + private fun buildSpliceHtml(originalHtml: String): String { + if (preloadJs.isNullOrEmpty()) return originalHtml + val headIndex = originalHtml.indexOf("= 0) { + val closingHeadIndex = originalHtml.indexOf('>', startIndex = headIndex) + if (closingHeadIndex >= 0) { + val insertPos = closingHeadIndex + 1 + StringBuilder(originalHtml).insert(insertPos, buildInjectJsScript()).toString() + } else { + buildInjectJsScript() + originalHtml + } + } else { + buildInjectJsScript() + originalHtml + } + } + + private fun buildInjectJsScript(): String { + val basicJs = basicJs + val preloadJs = this.preloadJs ?: "" + return """ + + """.trimIndent() + } + + private fun saveImage(webPic: String) { + val path = ACache.get().getAsString(imagePathKey) + if (path.isNullOrEmpty()) { + selectSaveFolder(webPic) + } else { + saveImage(webPic, path.toUri()) + } + } + + private fun selectSaveFolder(webPic: String?) { + val default = arrayListOf>() + val path = ACache.get().getAsString(imagePathKey) + if (!path.isNullOrEmpty()) { + default.add(SelectItem(path, -1)) + } + selectImageDir.launch { + otherActions = default + value = webPic + } + } + + private fun saveImage(webPic: String?, uri: Uri) { + webPic ?: return + Coroutine.async(lifecycleScope) { + val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" + val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("图片数据为空") + uri.writeBytes(requireContext(), fileName, byteArray) + }.onError { + ACache.get().remove(imagePathKey) + context?.toastOnUi("保存图片失败:${it.localizedMessage}") + }.onSuccess { + context?.toastOnUi("保存成功") + } + } + + private suspend fun webData2bitmap(data: String): ByteArray? { + return if (URLUtil.isValidUrl(data)) { + okHttpClient.newCallResponseBody { + url(data) + }.bytes() + } else { + runCatching { + Base64.decode(data.split(",").getOrNull(1) ?: "", Base64.DEFAULT) + }.getOrNull() } } override fun onDestroyView() { customWebViewCallback?.onCustomViewHidden() - - currentWebView.apply { - stopLoading() - loadUrl("about:blank") - clearHistory() - removeAllViews() - - webChromeClient = WebChromeClient() - webViewClient = WebViewClient() - - destroy() - } - - + binding.webViewContainer.removeView(currentWebView) + currentWebView.removeAllViews() + currentWebView.destroy() originOrientation?.let { activity?.requestedOrientation = it } - super.onDestroyView() } + override fun upConfig(config: String) { + try { + lifecycleScope.launch(Dispatchers.Main) { + GSON.fromJsonObject(config).getOrThrow().let { config -> + setConfig(config) + } + } + } catch (e: Exception) { + AppLog.put("config err", e) + } + } + + @Suppress("unused") private class JSInterface(dialog: BottomWebViewDialog) { - private val ref = WeakReference(dialog) + private val dialogRef: WeakReference = WeakReference(dialog) @JavascriptInterface fun lockOrientation(orientation: String) { - val fra = ref.get() ?: return - val act = fra.activity ?: return - + val fra = dialogRef.get() ?: return + val ctx = fra.requireActivity() if (fra.isFullScreen && fra.dialog?.isShowing == true) { - act.runOnUiThread { - act.requestedOrientation = when (orientation) { - "portrait", "portrait-primary" -> - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - - "portrait-secondary" -> - ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - - "landscape", "landscape-primary" -> - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - - "landscape-secondary" -> - ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - - "any", "unspecified" -> - ActivityInfo.SCREEN_ORIENTATION_SENSOR - - else -> - ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + fra.lifecycleScope.launch(Dispatchers.Main) { + ctx.requestedOrientation = when (orientation) { + "portrait", "portrait-primary" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + "portrait-secondary" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + "landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + "landscape-primary" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + "landscape-secondary" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + "any", "unspecified" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR + else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } } @@ -273,88 +629,188 @@ class BottomWebViewDialog() : @JavascriptInterface fun onCloseRequested() { - ref.get()?.activity?.runOnUiThread { - ref.get()?.dismiss() + val fra = dialogRef.get() ?: return + if (fra.dialog?.isShowing == true) { + fra.lifecycleScope.launch(Dispatchers.Main) { + fra.dismiss() + } } } } - inner class CustomWebChromeClient : WebChromeClient() { + @Keep + data class Config( + var state: Int? = null, + var peekHeight: Int? = null, + var isHideable: Boolean? = null, + var skipCollapsed: Boolean? = null, + var setHalfExpandedRatio: Float? = null, + var setExpandedOffset: Int? = null, + var setFitToContents: Boolean? = null, + var isDraggable: Boolean? = null, + var isDraggableOnNestedScroll: Boolean? = null, + var significantVelocityThreshold: Int? = null, + var hideFriction: Float? = null, + var maxWidth: Int? = null, + var maxHeight: Int? = null, + var isGestureInsetBottomIgnored: Boolean? = null, + var expandedCornersRadius: Float? = null, + var setUpdateImportantForAccessibilityOnSiblings: Boolean? = null, + var backgroundDimAmount: Float? = null, + var shouldDimBackground: Boolean? = null, + var webViewInitialScale: Int? = null, + var webViewCacheMode: Int? = null, + var dismissOnTouchOutside: Boolean? = null, + var hardwareAccelerated: Boolean? = null, + var isNestedScrollingEnabled: Boolean? = null, + var widthPercentage: Float? = null, + var heightPercentage: Float? = null, + var responsiveBreakpoint: Int? = null, + var dialogHeight: Int? = null, + var longClickSaveImg: Boolean? = null, + var scrollNoDraggable: Boolean? = null, + ) + inner class CustomWebChromeClient : WebChromeClient() { override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + originOrientation = activity?.requestedOrientation isFullScreen = true binding.webViewContainer.invisible() binding.customWebView.addView(view) customWebViewCallback = callback behavior?.state = BottomSheetBehavior.STATE_EXPANDED - originOrientation = activity?.requestedOrientation } override fun onHideCustomView() { + originOrientation?.let { + activity?.requestedOrientation = it + originOrientation = null + } isFullScreen = false binding.webViewContainer.visible() binding.customWebView.removeAllViews() customWebViewCallback = null - originOrientation?.let { - activity?.requestedOrientation = it - } } override fun onCloseWindow(window: WebView?) { dismiss() } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + if (!AppConfig.recordLog) return false + val source = source ?: return false + val messageLevel = consoleMessage.messageLevel().name + val message = consoleMessage.message() + AppLog.put( + "${source.getTag()}${messageLevel}: $message", + NoStackTraceException("\n${message}\n- Line ${consoleMessage.lineNumber()} of ${consoleMessage.sourceId()}") + ) + return true + } } inner class CustomWebViewClient : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - if (needClearHistory) { - needClearHistory = false - currentWebView.clearHistory() - } - super.onPageStarted(view, url, favicon) - currentWebView.evaluateJavascript(basicJs, null) - } - override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { - return request?.url?.let(::handleUri) ?: true + request?.url?.let { + return shouldOverrideUrlLoading(it) + } + return super.shouldOverrideUrlLoading(view, request) } - @Deprecated("Deprecated in Java") - @Suppress("DEPRECATION") - override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { - return url?.toUri()?.let(::handleUri) ?: true + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (needClearHistory) { + currentWebView.clearHistory() + needClearHistory = false + } + if (!isBasicJsInjected && view != null) { + view.evaluateJavascript(basicJs, null) + isBasicJsInjected = true + } } - private fun handleUri(uri: Uri): Boolean = - when (uri.scheme) { - "http", "https" -> false + private fun shouldOverrideUrlLoading(url: Uri): Boolean { + return when (url.scheme) { + "http", "https" -> { + false + } "legado", "yuedu" -> { - startActivity { data = uri } + startActivity { + data = url + } true } - else -> { - binding.root.longSnackbar( - R.string.jump_to_another_app, - R.string.confirm - ) { - activity?.openUrl(uri) + binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) { + activity?.openUrl(url) } true } } + } @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( - view: WebView?, - handler: SslErrorHandler?, - error: SslError? + view: WebView?, handler: SslErrorHandler?, error: SslError? ) { handler?.proceed() } + + private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() } + override fun shouldInterceptRequest( + view: WebView, request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + if (!request.isForMainFrame && url.contains(DEFAULT_JS_INJECT_URL)) { + val preloadJs = preloadJs ?: "" + return WebResourceResponse( + "text/javascript", + "utf-8", + ByteArrayInputStream("(() => {$JS_INJECTION\n$preloadJs\n})();".toByteArray()) + ) + } + if (request.isForMainFrame && !preloadJs.isNullOrEmpty()) { + return runBlocking(IO) { + getModifiedContentWithJs(url, request) ?: super.shouldInterceptRequest( + view, + request + ) + } + } + return super.shouldInterceptRequest(view, request) + } + + private suspend fun getModifiedContentWithJs( + url: String, + request: WebResourceRequest + ): WebResourceResponse? { + return try { + val cookie = webCookieManager.getCookie(url) + val res = okHttpClient.newCallResponse { + url(url) + method(request.method, null) + if (!cookie.isNullOrEmpty()) addHeader("Cookie", cookie) + request.requestHeaders?.forEach { (key, value) -> addHeader(key, value) } + } + res.headers("Set-Cookie").forEach { webCookieManager.setCookie(url, it) } + val body = res.body + val contentType = body.contentType() + val mimeType = contentType?.toString()?.substringBefore(";") ?: "text/html" + val charset = contentType?.charset() ?: Charsets.UTF_8 + val bodyText = buildSpliceHtml(body.text(charset.toString())) + WebResourceResponse( + mimeType, + charset.name(), + ByteArrayInputStream(bodyText.toByteArray(charset)) + ) + } catch (e: Exception) { + AppLog.put("拦截请求处理失败", e) + null + } + } } -} +} \ No newline at end of file