|
@@ -2,8 +2,9 @@ package io.legado.app.ui.browser
|
|
|
|
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.annotation.SuppressLint
|
|
|
import android.content.Intent
|
|
import android.content.Intent
|
|
|
-import android.view.View
|
|
|
|
|
|
|
+import android.net.Uri
|
|
|
import android.webkit.SslErrorHandler
|
|
import android.webkit.SslErrorHandler
|
|
|
|
|
+import android.webkit.URLUtil
|
|
|
import android.webkit.WebChromeClient
|
|
import android.webkit.WebChromeClient
|
|
|
import android.webkit.WebResourceRequest
|
|
import android.webkit.WebResourceRequest
|
|
|
import android.webkit.WebSettings
|
|
import android.webkit.WebSettings
|
|
@@ -16,10 +17,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.padding
|
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.Icons
|
|
|
import androidx.compose.material.icons.filled.Check
|
|
import androidx.compose.material.icons.filled.Check
|
|
|
|
|
+import androidx.compose.material.icons.filled.ContentCopy
|
|
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
|
|
import androidx.compose.material.icons.filled.Refresh
|
|
import androidx.compose.material.icons.filled.Refresh
|
|
|
|
|
+import androidx.compose.material.icons.filled.Save
|
|
|
|
|
+import androidx.compose.material3.SnackbarHost
|
|
|
|
|
+import androidx.compose.material3.SnackbarHostState
|
|
|
|
|
+import androidx.compose.material3.SnackbarResult
|
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.Composable
|
|
|
-import androidx.compose.runtime.DisposableEffect
|
|
|
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.getValue
|
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
@@ -28,18 +33,24 @@ import androidx.compose.runtime.remember
|
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.setValue
|
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.Modifier
|
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalContext
|
|
|
-import androidx.compose.ui.viewinterop.AndroidView
|
|
|
|
|
import io.legado.app.R
|
|
import io.legado.app.R
|
|
|
import io.legado.app.constant.AppConst
|
|
import io.legado.app.constant.AppConst
|
|
|
import io.legado.app.help.http.CookieManager
|
|
import io.legado.app.help.http.CookieManager
|
|
|
import io.legado.app.help.http.CookieStore
|
|
import io.legado.app.help.http.CookieStore
|
|
|
import io.legado.app.help.source.SourceVerificationHelp
|
|
import io.legado.app.help.source.SourceVerificationHelp
|
|
|
|
|
+import io.legado.app.model.Download
|
|
|
|
|
+import io.legado.app.ui.association.OnLineImportActivity
|
|
|
|
|
+import io.legado.app.ui.rss.read.VisibleWebViewCompose
|
|
|
import io.legado.app.ui.widget.components.AppScaffold
|
|
import io.legado.app.ui.widget.components.AppScaffold
|
|
|
|
|
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
|
|
|
|
|
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
|
|
|
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
|
|
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
|
|
|
import io.legado.app.ui.widget.components.topbar.GlassSmallTopAppBar
|
|
import io.legado.app.ui.widget.components.topbar.GlassSmallTopAppBar
|
|
|
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
|
|
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
|
|
|
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
|
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
|
|
import io.legado.app.utils.openUrl
|
|
import io.legado.app.utils.openUrl
|
|
|
|
|
+import io.legado.app.utils.sendToClip
|
|
|
|
|
+import java.net.URLDecoder
|
|
|
import android.webkit.CookieManager as AndroidCookieManager
|
|
import android.webkit.CookieManager as AndroidCookieManager
|
|
|
|
|
|
|
|
@SuppressLint("SetJavaScriptEnabled", "WebViewClientOnReceivedSslError")
|
|
@SuppressLint("SetJavaScriptEnabled", "WebViewClientOnReceivedSslError")
|
|
@@ -48,14 +59,17 @@ fun WebViewRouteScreen(
|
|
|
intent: Intent,
|
|
intent: Intent,
|
|
|
viewModel: WebViewModel,
|
|
viewModel: WebViewModel,
|
|
|
onFinish: () -> Unit,
|
|
onFinish: () -> Unit,
|
|
|
|
|
+ onImportBookSource: (String) -> Unit,
|
|
|
) {
|
|
) {
|
|
|
val context = LocalContext.current
|
|
val context = LocalContext.current
|
|
|
var ready by remember { mutableStateOf(false) }
|
|
var ready by remember { mutableStateOf(false) }
|
|
|
var progress by remember { mutableIntStateOf(0) }
|
|
var progress by remember { mutableIntStateOf(0) }
|
|
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
|
|
- var customView by remember { mutableStateOf<View?>(null) }
|
|
|
|
|
- var customViewCallback by remember { mutableStateOf<WebChromeClient.CustomViewCallback?>(null) }
|
|
|
|
|
var cloudflareChallenge by remember { mutableStateOf(false) }
|
|
var cloudflareChallenge by remember { mutableStateOf(false) }
|
|
|
|
|
+ var pageTitle by remember { mutableStateOf<String?>(null) }
|
|
|
|
|
+ var imageUrlToSave by remember { mutableStateOf<String?>(null) }
|
|
|
|
|
+ var pendingDownload by remember { mutableStateOf<Pair<String, String>?>(null) }
|
|
|
|
|
+ val snackbarHostState = remember { SnackbarHostState() }
|
|
|
|
|
|
|
|
LaunchedEffect(intent) {
|
|
LaunchedEffect(intent) {
|
|
|
viewModel.initData(intent) { ready = true }
|
|
viewModel.initData(intent) { ready = true }
|
|
@@ -75,7 +89,6 @@ fun WebViewRouteScreen(
|
|
|
|
|
|
|
|
BackHandler {
|
|
BackHandler {
|
|
|
when {
|
|
when {
|
|
|
- customView != null -> customViewCallback?.onCustomViewHidden()
|
|
|
|
|
webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size
|
|
webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size
|
|
|
?: 0) > 1 -> webView?.goBack()
|
|
?: 0) > 1 -> webView?.goBack()
|
|
|
|
|
|
|
@@ -86,9 +99,10 @@ fun WebViewRouteScreen(
|
|
|
AppScaffold(
|
|
AppScaffold(
|
|
|
// WebView 内容不能作为 haze 模糊采样源,否则会被反复重采样导致闪烁(与 RSS 页一致)
|
|
// WebView 内容不能作为 haze 模糊采样源,否则会被反复重采样导致闪烁(与 RSS 页一致)
|
|
|
disableHazeSource = true,
|
|
disableHazeSource = true,
|
|
|
|
|
+ snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
|
topBar = {
|
|
topBar = {
|
|
|
GlassSmallTopAppBar(
|
|
GlassSmallTopAppBar(
|
|
|
- title = viewModel.sourceName.ifBlank {
|
|
|
|
|
|
|
+ title = pageTitle ?: viewModel.sourceName.ifBlank {
|
|
|
intent.getStringExtra("title") ?: context.getString(R.string.loading)
|
|
intent.getStringExtra("title") ?: context.getString(R.string.loading)
|
|
|
},
|
|
},
|
|
|
navigationIcon = { TopBarNavigationButton(onClick = ::finishScreen) },
|
|
navigationIcon = { TopBarNavigationButton(onClick = ::finishScreen) },
|
|
@@ -99,10 +113,26 @@ fun WebViewRouteScreen(
|
|
|
contentDescription = context.getString(R.string.refresh),
|
|
contentDescription = context.getString(R.string.refresh),
|
|
|
)
|
|
)
|
|
|
TopBarActionButton(
|
|
TopBarActionButton(
|
|
|
- onClick = { webView?.url?.let(context::openUrl) },
|
|
|
|
|
|
|
+ onClick = {
|
|
|
|
|
+ val currentUrl = webView?.url
|
|
|
|
|
+ val externalUrl = currentUrl
|
|
|
|
|
+ ?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
|
|
|
|
+ ?: viewModel.baseUrl
|
|
|
|
|
+ context.openUrl(externalUrl)
|
|
|
|
|
+ },
|
|
|
imageVector = Icons.Default.OpenInBrowser,
|
|
imageVector = Icons.Default.OpenInBrowser,
|
|
|
contentDescription = context.getString(R.string.open_in_browser),
|
|
contentDescription = context.getString(R.string.open_in_browser),
|
|
|
)
|
|
)
|
|
|
|
|
+ TopBarActionButton(
|
|
|
|
|
+ onClick = {
|
|
|
|
|
+ val currentUrl = webView?.url.orEmpty()
|
|
|
|
|
+ .takeIf { it.isNotBlank() }
|
|
|
|
|
+ ?: viewModel.baseUrl
|
|
|
|
|
+ context.sendToClip(currentUrl)
|
|
|
|
|
+ },
|
|
|
|
|
+ imageVector = Icons.Default.ContentCopy,
|
|
|
|
|
+ contentDescription = context.getString(R.string.copy_url),
|
|
|
|
|
+ )
|
|
|
TopBarActionButton(
|
|
TopBarActionButton(
|
|
|
onClick = ::finishWithVerification,
|
|
onClick = ::finishWithVerification,
|
|
|
imageVector = Icons.Default.Check,
|
|
imageVector = Icons.Default.Check,
|
|
@@ -116,12 +146,10 @@ fun WebViewRouteScreen(
|
|
|
.fillMaxSize()
|
|
.fillMaxSize()
|
|
|
.padding(paddingValues)) {
|
|
.padding(paddingValues)) {
|
|
|
if (ready) {
|
|
if (ready) {
|
|
|
- AndroidView(
|
|
|
|
|
|
|
+ VisibleWebViewCompose(
|
|
|
modifier = Modifier.fillMaxSize(),
|
|
modifier = Modifier.fillMaxSize(),
|
|
|
- factory = { viewContext ->
|
|
|
|
|
- WebView(viewContext).apply {
|
|
|
|
|
- // 透明背景,避免页面加载/跳转时白屏闪烁(与 RSS 的 VisibleWebView 一致)
|
|
|
|
|
- setBackgroundColor(0)
|
|
|
|
|
|
|
+ onCreated = { createdWebView ->
|
|
|
|
|
+ createdWebView.apply {
|
|
|
settings.apply {
|
|
settings.apply {
|
|
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
|
domStorageEnabled = true
|
|
domStorageEnabled = true
|
|
@@ -131,18 +159,64 @@ fun WebViewRouteScreen(
|
|
|
builtInZoomControls = true
|
|
builtInZoomControls = true
|
|
|
displayZoomControls = false
|
|
displayZoomControls = false
|
|
|
javaScriptEnabled = true
|
|
javaScriptEnabled = true
|
|
|
- viewModel.headerMap[AppConst.UA_NAME]?.let { userAgentString = it }
|
|
|
|
|
|
|
+ viewModel.headerMap[AppConst.UA_NAME]?.let {
|
|
|
|
|
+ userAgentString = it
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ isClickable = true
|
|
|
|
|
+ isFocusable = true
|
|
|
|
|
+ isFocusableInTouchMode = true
|
|
|
|
|
+ val currentWebView = this
|
|
|
|
|
+ AndroidCookieManager.getInstance().apply {
|
|
|
|
|
+ setAcceptCookie(true)
|
|
|
|
|
+ setAcceptThirdPartyCookies(currentWebView, true)
|
|
|
}
|
|
}
|
|
|
webViewClient = object : WebViewClient() {
|
|
webViewClient = object : WebViewClient() {
|
|
|
override fun shouldOverrideUrlLoading(
|
|
override fun shouldOverrideUrlLoading(
|
|
|
view: WebView,
|
|
view: WebView,
|
|
|
request: WebResourceRequest
|
|
request: WebResourceRequest
|
|
|
- ): Boolean =
|
|
|
|
|
- request.url.scheme !in setOf("http", "https")
|
|
|
|
|
|
|
+ ): Boolean = handleUrl(request.url)
|
|
|
|
|
+
|
|
|
|
|
+ @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
|
|
|
|
+ override fun shouldOverrideUrlLoading(
|
|
|
|
|
+ view: WebView,
|
|
|
|
|
+ url: String,
|
|
|
|
|
+ ): Boolean = handleUrl(Uri.parse(url))
|
|
|
|
|
+
|
|
|
|
|
+ private fun handleUrl(uri: Uri): Boolean {
|
|
|
|
|
+ return when (uri.scheme) {
|
|
|
|
|
+ "http", "https" -> false
|
|
|
|
|
+ "legado", "yuedu" -> {
|
|
|
|
|
+ val importUrl = uri.getQueryParameter("src")
|
|
|
|
|
+ val isBookSourceImport = uri.path == "/bookSource" ||
|
|
|
|
|
+ (uri.path == "/importonline" &&
|
|
|
|
|
+ uri.host.equals("booksource", true))
|
|
|
|
|
+ if (isBookSourceImport && !importUrl.isNullOrBlank()) {
|
|
|
|
|
+ onImportBookSource(importUrl)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ context.startActivity(
|
|
|
|
|
+ Intent(
|
|
|
|
|
+ context,
|
|
|
|
|
+ OnLineImportActivity::class.java
|
|
|
|
|
+ )
|
|
|
|
|
+ .setData(uri)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ else -> {
|
|
|
|
|
+ context.openUrl(uri)
|
|
|
|
|
+ true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
override fun onPageFinished(view: WebView, url: String) {
|
|
override fun onPageFinished(view: WebView, url: String) {
|
|
|
|
|
+ pageTitle = view.title
|
|
|
|
|
+ ?.takeIf { it.isNotBlank() && it != url && it != view.url }
|
|
|
AndroidCookieManager.getInstance().getCookie(url)
|
|
AndroidCookieManager.getInstance().getCookie(url)
|
|
|
- ?.let { CookieStore.setCookie(viewModel.sourceOrigin, it) }
|
|
|
|
|
|
|
+ ?.let { CookieStore.setCookie(url, it) }
|
|
|
if (viewModel.sourceVerificationEnable) view.evaluateJavascript(
|
|
if (viewModel.sourceVerificationEnable) view.evaluateJavascript(
|
|
|
"!!window._cf_chl_opt"
|
|
"!!window._cf_chl_opt"
|
|
|
) {
|
|
) {
|
|
@@ -166,17 +240,26 @@ fun WebViewRouteScreen(
|
|
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
|
progress = newProgress
|
|
progress = newProgress
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- override fun onShowCustomView(
|
|
|
|
|
- view: View?,
|
|
|
|
|
- callback: CustomViewCallback?
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ setOnLongClickListener {
|
|
|
|
|
+ val result = hitTestResult
|
|
|
|
|
+ if (result.type == WebView.HitTestResult.IMAGE_TYPE ||
|
|
|
|
|
+ result.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
|
|
|
) {
|
|
) {
|
|
|
- customView = view; customViewCallback = callback
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- override fun onHideCustomView() {
|
|
|
|
|
- customView = null; customViewCallback = null
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ imageUrlToSave = result.extra
|
|
|
|
|
+ imageUrlToSave != null
|
|
|
|
|
+ } else false
|
|
|
|
|
+ }
|
|
|
|
|
+ setDownloadListener { url, _, contentDisposition, mimeType, _ ->
|
|
|
|
|
+ val encodedName = URLUtil.guessFileName(
|
|
|
|
|
+ url,
|
|
|
|
|
+ contentDisposition,
|
|
|
|
|
+ mimeType,
|
|
|
|
|
+ )
|
|
|
|
|
+ val fileName = runCatching {
|
|
|
|
|
+ URLDecoder.decode(encodedName, "UTF-8")
|
|
|
|
|
+ }.getOrDefault(encodedName)
|
|
|
|
|
+ pendingDownload = url to fileName
|
|
|
}
|
|
}
|
|
|
CookieManager.applyToWebView(viewModel.baseUrl)
|
|
CookieManager.applyToWebView(viewModel.baseUrl)
|
|
|
val html = viewModel.html
|
|
val html = viewModel.html
|
|
@@ -199,5 +282,32 @@ fun WebViewRouteScreen(
|
|
|
if (progress in 0..99) AppLinearProgressIndicator(Modifier.fillMaxWidth())
|
|
if (progress in 0..99) AppLinearProgressIndicator(Modifier.fillMaxWidth())
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- DisposableEffect(Unit) { onDispose { webView?.destroy() } }
|
|
|
|
|
|
|
+ OptionSheet(
|
|
|
|
|
+ show = imageUrlToSave != null,
|
|
|
|
|
+ onDismissRequest = { imageUrlToSave = null },
|
|
|
|
|
+ title = context.getString(R.string.select_operation),
|
|
|
|
|
+ ) {
|
|
|
|
|
+ OptionCard(
|
|
|
|
|
+ icon = Icons.Default.Save,
|
|
|
|
|
+ text = context.getString(R.string.action_save),
|
|
|
|
|
+ onClick = {
|
|
|
|
|
+ val imageUrl = imageUrlToSave
|
|
|
|
|
+ imageUrlToSave = null
|
|
|
|
|
+ viewModel.saveImage(imageUrl)
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ val download = pendingDownload
|
|
|
|
|
+ LaunchedEffect(download) {
|
|
|
|
|
+ download ?: return@LaunchedEffect
|
|
|
|
|
+ val result = snackbarHostState.showSnackbar(
|
|
|
|
|
+ message = download.second,
|
|
|
|
|
+ actionLabel = context.getString(R.string.action_download),
|
|
|
|
|
+ withDismissAction = true,
|
|
|
|
|
+ )
|
|
|
|
|
+ if (result == SnackbarResult.ActionPerformed) {
|
|
|
|
|
+ Download.start(context, download.first, download.second)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pendingDownload == download) pendingDownload = null
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|