[优化] 完整支持半屏段评

This commit is contained in:
HapeLee
2026-02-02 20:56:10 +08:00
parent d95774fd40
commit d3c85eccfe
@@ -1,36 +1,54 @@
package io.legado.app.ui.widget.dialog package io.legado.app.ui.widget.dialog
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.CustomViewCallback
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.isNotEmpty import androidx.core.view.size
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.constant.AppConst.imagePathKey
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BaseSource
import io.legado.app.databinding.DialogWebViewBinding import io.legado.app.databinding.DialogWebViewBinding
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.WebCacheManager import io.legado.app.help.WebCacheManager
import io.legado.app.help.config.AppConfig 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
import io.legado.app.help.webView.WebJsExtensions.Companion.JS_INJECTION import io.legado.app.help.webView.WebJsExtensions.Companion.JS_INJECTION
import io.legado.app.help.webView.WebJsExtensions.Companion.basicJs 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.nameCache
import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava
import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource 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.model.analyzeRule.AnalyzeUrl
import io.legado.app.ui.association.OnLineImportActivity 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.invisible
import io.legado.app.utils.longSnackbar import io.legado.app.utils.longSnackbar
import io.legado.app.utils.openUrl import io.legado.app.utils.openUrl
import io.legado.app.utils.setLayout
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible 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.launch
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Date
class BottomWebViewDialog() : class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view),
BaseBottomSheetDialogFragment(R.layout.dialog_web_view) { WebJsExtensions.Callback {
companion object {
const val DEFAULT_JS_INJECT_URL = "https://legado-inject-js.internal"
}
constructor( constructor(
sourceKey: String, sourceKey: String,
@@ -74,25 +107,45 @@ class BottomWebViewDialog() :
} }
private val binding by viewBinding(DialogWebViewBinding::bind) private val binding by viewBinding(DialogWebViewBinding::bind)
private val bottomSheet by lazy { private val bottomSheet by lazy {
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
} }
private val behavior by lazy { 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 lateinit var currentWebView: WebView
private var source: BaseSource? = null private var source: BaseSource? = null
private var preloadJs: String? = null
private var isFullScreen = false private var isFullScreen = false
private var customWebViewCallback: CustomViewCallback? = null private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
private var originOrientation: Int? = null private var originOrientation: Int? = null
private var needClearHistory = true 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?) { override fun show(manager: FragmentManager, tag: String?) {
runCatching { kotlin.runCatching {
manager.beginTransaction().remove(this).commit() manager.beginTransaction().remove(this).commit()
super.show(manager, tag) super.show(manager, tag)
}.onFailure { }.onFailure {
@@ -100,90 +153,329 @@ class BottomWebViewDialog() :
} }
} }
override fun onFragmentCreated( private fun setConfig(config: Config, first: Boolean = false) {
view: View, behavior?.let { behavior ->
savedInstanceState: Bundle? config.state?.let { behavior.state = it }
) { config.peekHeight?.let { behavior.peekHeight = it }
if (!AppConfig.isEInkMode) { config.isHideable?.let { behavior.isHideable = it }
view.setBackgroundColor(ThemeStore.backgroundColor()) 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() config.expandedCornersRadius?.let {
binding.webViewContainer.addView(currentWebView) val radius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, it, displayMetrics
lifecycleScope.launch { )
val args = arguments ?: run { bottomSheet?.let { sheet ->
dismiss(); return@launch if (radius > 0) {
} sheet.backgroundTintList = null
val shapeDrawable =
val sourceKey = args.getString("sourceKey") ?: return@launch android.graphics.drawable.GradientDrawable().apply {
val url = args.getString("url") ?: return@launch cornerRadius = 0f
var html = args.getString("html") ?: return@launch cornerRadii = floatArrayOf(
radius, radius,
args.getString("preloadJs")?.let { preloadJs -> radius, radius,
html = if (html.contains("<head>")) { 0f, 0f,
html.replaceFirst( 0f, 0f
"<head>", )
"<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script>" }
) 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 { } else {
"<head><script>(() => {$JS_INJECTION\n$preloadJs\n})();</script></head>$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 -> dialog?.let { dialog ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { config.backgroundDimAmount?.let { amount ->
when { dialog.window?.setDimAmount(amount)
binding.customWebView.isNotEmpty() -> { }
customWebViewCallback?.onCustomViewHidden() config.shouldDimBackground?.let { shouldDim ->
true 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.let { webView ->
currentWebView.goBack() config.webViewInitialScale?.let { scale ->
true 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 -> { bottomSheet?.let { sheet ->
dismiss() val params = sheet.layoutParams
true 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 = private fun setLongClickSaveImg() {
WebView(requireContext()).apply { currentWebView.setOnLongClickListener {
layoutParams = ViewGroup.LayoutParams( val hitTestResult = currentWebView.hitTestResult
ViewGroup.LayoutParams.MATCH_PARENT, if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
ViewGroup.LayoutParams.MATCH_PARENT 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<Config>(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,
"<html><body style='color:red;'>加载失败:${it.localizedMessage}</body></html>",
"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( private fun initWebView(
url: String, url: String,
@@ -191,81 +483,145 @@ class BottomWebViewDialog() :
headerMap: HashMap<String, String>, headerMap: HashMap<String, String>,
bookType: Int bookType: Int
) { ) {
currentWebView.apply { currentWebView.webViewClient = CustomWebViewClient()
webChromeClient = CustomWebChromeClient() currentWebView.webChromeClient = CustomWebChromeClient()
webViewClient = CustomWebViewClient() headerMap[AppConst.UA_NAME]?.let {
currentWebView.settings.userAgentString = it
settings.userAgentString = }
headerMap.get(AppConst.UA_NAME, true) currentWebView.addJavascriptInterface(JSInterface(this), nameBasic)
source?.let { source ->
addJavascriptInterface(JSInterface(this@BottomWebViewDialog), nameBasic) (activity as? AppCompatActivity)?.let { currentActivity ->
val webJsExtensions = WebJsExtensions(
source?.let { source -> source, currentActivity, currentWebView, bookType, callback = this
(activity as? AppCompatActivity)?.let { act -> )
addJavascriptInterface( currentWebView.addJavascriptInterface(webJsExtensions, nameJava)
WebJsExtensions(source, act, this, bookType),
nameJava
)
}
addJavascriptInterface(source, nameSource)
addJavascriptInterface(WebCacheManager, nameCache)
} }
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("<head", ignoreCase = true)
return if (headIndex >= 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 """
<script type="text/javascript">
// 基础JS
$basicJs
// 预加载业务JS
(() => {
$JS_INJECTION
$preloadJs
})();
</script>
""".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<SelectItem<Int>>()
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() { override fun onDestroyView() {
customWebViewCallback?.onCustomViewHidden() customWebViewCallback?.onCustomViewHidden()
binding.webViewContainer.removeView(currentWebView)
currentWebView.apply { currentWebView.removeAllViews()
stopLoading() currentWebView.destroy()
loadUrl("about:blank")
clearHistory()
removeAllViews()
webChromeClient = WebChromeClient()
webViewClient = WebViewClient()
destroy()
}
originOrientation?.let { originOrientation?.let {
activity?.requestedOrientation = it activity?.requestedOrientation = it
} }
super.onDestroyView() super.onDestroyView()
} }
override fun upConfig(config: String) {
try {
lifecycleScope.launch(Dispatchers.Main) {
GSON.fromJsonObject<Config>(config).getOrThrow().let { config ->
setConfig(config)
}
}
} catch (e: Exception) {
AppLog.put("config err", e)
}
}
@Suppress("unused")
private class JSInterface(dialog: BottomWebViewDialog) { private class JSInterface(dialog: BottomWebViewDialog) {
private val ref = WeakReference(dialog) private val dialogRef: WeakReference<BottomWebViewDialog> = WeakReference(dialog)
@JavascriptInterface @JavascriptInterface
fun lockOrientation(orientation: String) { fun lockOrientation(orientation: String) {
val fra = ref.get() ?: return val fra = dialogRef.get() ?: return
val act = fra.activity ?: return val ctx = fra.requireActivity()
if (fra.isFullScreen && fra.dialog?.isShowing == true) { if (fra.isFullScreen && fra.dialog?.isShowing == true) {
act.runOnUiThread { fra.lifecycleScope.launch(Dispatchers.Main) {
act.requestedOrientation = when (orientation) { ctx.requestedOrientation = when (orientation) {
"portrait", "portrait-primary" -> "portrait", "portrait-primary" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT "portrait-secondary" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
"portrait-secondary" -> "landscape-primary" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT "landscape-secondary" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
"any", "unspecified" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR
"landscape", "landscape-primary" -> else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
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 @JavascriptInterface
fun onCloseRequested() { fun onCloseRequested() {
ref.get()?.activity?.runOnUiThread { val fra = dialogRef.get() ?: return
ref.get()?.dismiss() 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?) { override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
originOrientation = activity?.requestedOrientation
isFullScreen = true isFullScreen = true
binding.webViewContainer.invisible() binding.webViewContainer.invisible()
binding.customWebView.addView(view) binding.customWebView.addView(view)
customWebViewCallback = callback customWebViewCallback = callback
behavior?.state = BottomSheetBehavior.STATE_EXPANDED behavior?.state = BottomSheetBehavior.STATE_EXPANDED
originOrientation = activity?.requestedOrientation
} }
override fun onHideCustomView() { override fun onHideCustomView() {
originOrientation?.let {
activity?.requestedOrientation = it
originOrientation = null
}
isFullScreen = false isFullScreen = false
binding.webViewContainer.visible() binding.webViewContainer.visible()
binding.customWebView.removeAllViews() binding.customWebView.removeAllViews()
customWebViewCallback = null customWebViewCallback = null
originOrientation?.let {
activity?.requestedOrientation = it
}
} }
override fun onCloseWindow(window: WebView?) { override fun onCloseWindow(window: WebView?) {
dismiss() 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() { 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( override fun shouldOverrideUrlLoading(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
): Boolean { ): Boolean {
return request?.url?.let(::handleUri) ?: true request?.url?.let {
return shouldOverrideUrlLoading(it)
}
return super.shouldOverrideUrlLoading(view, request)
} }
@Deprecated("Deprecated in Java") override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
@Suppress("DEPRECATION") super.onPageStarted(view, url, favicon)
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { if (needClearHistory) {
return url?.toUri()?.let(::handleUri) ?: true currentWebView.clearHistory()
needClearHistory = false
}
if (!isBasicJsInjected && view != null) {
view.evaluateJavascript(basicJs, null)
isBasicJsInjected = true
}
} }
private fun handleUri(uri: Uri): Boolean = private fun shouldOverrideUrlLoading(url: Uri): Boolean {
when (uri.scheme) { return when (url.scheme) {
"http", "https" -> false "http", "https" -> {
false
}
"legado", "yuedu" -> { "legado", "yuedu" -> {
startActivity<OnLineImportActivity> { data = uri } startActivity<OnLineImportActivity> {
data = url
}
true true
} }
else -> { else -> {
binding.root.longSnackbar( binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
R.string.jump_to_another_app, activity?.openUrl(url)
R.string.confirm
) {
activity?.openUrl(uri)
} }
true true
} }
} }
}
@SuppressLint("WebViewClientOnReceivedSslError") @SuppressLint("WebViewClientOnReceivedSslError")
override fun onReceivedSslError( override fun onReceivedSslError(
view: WebView?, view: WebView?, handler: SslErrorHandler?, error: SslError?
handler: SslErrorHandler?,
error: SslError?
) { ) {
handler?.proceed() 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
}
}
} }
} }