HapeLee 2 месяцев назад
Родитель
Сommit
e38b11c781

+ 2 - 7
app/src/main/java/io/legado/app/help/source/RssSourceExtensions.kt

@@ -1,10 +1,9 @@
 package io.legado.app.help.source
 package io.legado.app.help.source
 
 
+import com.script.rhino.runScriptWithContext
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssSource
 import io.legado.app.utils.ACache
 import io.legado.app.utils.ACache
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.MD5Utils
-import io.legado.app.utils.NetworkUtils
-import com.script.rhino.runScriptWithContext
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 
 
@@ -40,11 +39,7 @@ suspend fun RssSource.sortUrls(): List<Pair<String, String>> {
                     val name = sort.substringBefore("::")
                     val name = sort.substringBefore("::")
                     val url = sort.substringAfter("::", "")
                     val url = sort.substringAfter("::", "")
                     if (url.isNotEmpty()) {
                     if (url.isNotEmpty()) {
-                        if (url.startsWith("{{")) {
-                            add(Pair(name, url))
-                        } else {
-                            add(Pair(name, NetworkUtils.getAbsoluteURL(sourceUrl, url)))
-                        }
+                        add(Pair(name, url))
                     }
                     }
                 }
                 }
                 if (isEmpty()) {
                 if (isEmpty()) {

+ 3 - 0
app/src/main/java/io/legado/app/model/rss/Rss.kt

@@ -47,6 +47,8 @@ object Rss {
         val analyzeUrl = AnalyzeUrl(
         val analyzeUrl = AnalyzeUrl(
             sortUrl,
             sortUrl,
             page = page,
             page = page,
+            key = searchKey,
+            baseUrl = rssSource.sourceUrl,
             source = rssSource,
             source = rssSource,
             ruleData = ruleData,
             ruleData = ruleData,
             coroutineContext = coroutineContext,
             coroutineContext = coroutineContext,
@@ -78,6 +80,7 @@ object Rss {
             }
             }
         }
         }
         checkRedirect(rssSource, res)
         checkRedirect(rssSource, res)
+        Debug.log(rssSource.sourceUrl, "≡获取成功:${analyzeUrl.ruleUrl}")
         return RssParserByRule.parseXML(sortName, sortUrl, res.url, res.body, rssSource, ruleData)
         return RssParserByRule.parseXML(sortName, sortUrl, res.url, res.body, rssSource, ruleData)
     }
     }
 
 

+ 2 - 2
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -954,10 +954,10 @@ fun MainActivity.mainEntryProvider(
             openUrl = route.openUrl,
             openUrl = route.openUrl,
             startPage = route.startPage,
             startPage = route.startPage,
             onBackClick = { onNavigateBack() },
             onBackClick = { onNavigateBack() },
-            onOpenArticles = { sortUrl ->
+            onOpenArticles = { sortUrl, targetOrigin ->
                 onNavigateToRoute(
                 onNavigateToRoute(
                     MainRouteRssSort(
                     MainRouteRssSort(
-                        sourceUrl = route.origin,
+                        sourceUrl = targetOrigin ?: route.origin,
                         sortUrl = sortUrl
                         sortUrl = sortUrl
                     )
                     )
                 )
                 )

+ 21 - 6
app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt

@@ -9,7 +9,6 @@ import com.script.rhino.runScriptWithContext
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssSource
-import io.legado.app.help.config.AppConfig
 import io.legado.app.data.entities.RssStar
 import io.legado.app.data.entities.RssStar
 import io.legado.app.data.repository.RssArticleRepository
 import io.legado.app.data.repository.RssArticleRepository
 import io.legado.app.data.repository.RssFavoriteRepository
 import io.legado.app.data.repository.RssFavoriteRepository
@@ -18,6 +17,7 @@ import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.TTS
 import io.legado.app.help.TTS
+import io.legado.app.help.config.AppConfig
 import io.legado.app.help.http.newCallResponseBody
 import io.legado.app.help.http.newCallResponseBody
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.webView.WebJsExtensions
 import io.legado.app.help.webView.WebJsExtensions
@@ -27,10 +27,10 @@ import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.stateIn
 import splitties.init.appCtx
 import splitties.init.appCtx
 import kotlin.coroutines.coroutineContext
 import kotlin.coroutines.coroutineContext
@@ -43,6 +43,11 @@ data class ReadRssArgs(
     val startPage: Boolean = false
     val startPage: Boolean = false
 )
 )
 
 
+internal fun shouldPreserveRssArticleOnRefresh(
+    ruleDescription: String?,
+    ruleContent: String?,
+) = ruleContent.isNullOrBlank() || !ruleDescription.isNullOrBlank()
+
 data class ReadRssSettings(
 data class ReadRssSettings(
     val showStatusBar: Boolean = true,
     val showStatusBar: Boolean = true,
     val userAgent: String = "",
     val userAgent: String = "",
@@ -68,6 +73,7 @@ class ReadRssViewModel(
     var rssSource: RssSource? = null
     var rssSource: RssSource? = null
     var rssArticle: RssArticle? = null
     var rssArticle: RssArticle? = null
     var tts: TTS? = null
     var tts: TTS? = null
+    var hasPreloadJs = false
     var headerMap: Map<String, String> = emptyMap()
     var headerMap: Map<String, String> = emptyMap()
     private var isStartPage = false
     private var isStartPage = false
 
 
@@ -98,6 +104,7 @@ class ReadRssViewModel(
     fun initData(args: ReadRssArgs) {
     fun initData(args: ReadRssArgs) {
         execute {
         execute {
             rssSource = rssRepository.getByKey(args.origin)
             rssSource = rssRepository.getByKey(args.origin)
+            hasPreloadJs = !rssSource?.preloadJs.isNullOrBlank()
             headerMap = runScriptWithContext {
             headerMap = runScriptWithContext {
                 rssSource?.getHeaderMap(AppConfig.userAgent) ?: emptyMap()
                 rssSource?.getHeaderMap(AppConfig.userAgent) ?: emptyMap()
             }
             }
@@ -195,16 +202,24 @@ class ReadRssViewModel(
     }
     }
 
 
     fun refresh(finish: () -> Unit) {
     fun refresh(finish: () -> Unit) {
+        val source = rssSource ?: run {
+            appCtx.toastOnUi("订阅源不存在")
+            finish.invoke()
+            return
+        }
+        if (source.singleUrl == true) {
+            finish.invoke()
+            return
+        }
         rssArticle?.let { article ->
         rssArticle?.let { article ->
-            rssSource?.let {
-                val ruleContent = it.ruleContent
+            val ruleContent = source.ruleContent
+            if (shouldPreserveRssArticleOnRefresh(source.ruleDescription, ruleContent)) {
                 if (!ruleContent.isNullOrBlank()) {
                 if (!ruleContent.isNullOrBlank()) {
                     loadContent(article, ruleContent)
                     loadContent(article, ruleContent)
                 } else {
                 } else {
                     finish.invoke()
                     finish.invoke()
                 }
                 }
-            } ?: let {
-                appCtx.toastOnUi("订阅源不存在")
+            } else {
                 finish.invoke()
                 finish.invoke()
             }
             }
         } ?: finish.invoke()
         } ?: finish.invoke()

+ 142 - 24
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -36,6 +36,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
@@ -55,9 +56,11 @@ import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -87,7 +90,7 @@ fun RssReadRouteScreen(
     openUrl: String?,
     openUrl: String?,
     startPage: Boolean,
     startPage: Boolean,
     onBackClick: () -> Unit,
     onBackClick: () -> Unit,
-    onOpenArticles: (sortUrl: String?) -> Unit,
+    onOpenArticles: (sortUrl: String?, targetOrigin: String?) -> Unit,
     viewModel: ReadRssViewModel = koinViewModel()
     viewModel: ReadRssViewModel = koinViewModel()
 ) {
 ) {
     val context = LocalContext.current
     val context = LocalContext.current
@@ -108,6 +111,10 @@ fun RssReadRouteScreen(
     }
     }
 
 
     var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
     var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
+    val refreshNameList = remember { mutableStateListOf<String>() }
+    var redirectRequest by remember { mutableStateOf<RedirectRequest?>(null) }
+    var showLogSheet by remember { mutableStateOf(false) }
+    var injectedJsSourceUrl by remember { mutableStateOf<String?>(null) }
 
 
     var showFavoriteSheet by remember { mutableStateOf(false) }
     var showFavoriteSheet by remember { mutableStateOf(false) }
     var favoriteTitle by remember { mutableStateOf("") }
     var favoriteTitle by remember { mutableStateOf("") }
@@ -128,6 +135,36 @@ fun RssReadRouteScreen(
         activity?.toggleSystemBar(settings.showStatusBar)
         activity?.toggleSystemBar(settings.showStatusBar)
     }
     }
 
 
+    val controllerCallbacks = remember {
+        RssReadWebControllerCallbacks(
+            onProgressChanged = { webProgress = it },
+            onPageTitleResolved = { resolved ->
+                pageTitle = resolved.ifBlank { defaultTopBarTitle }
+            },
+            onShowCustomView = { view, callback ->
+                if (view == null) {
+                    callback?.onCustomViewHidden()
+                } else if (customView != null) {
+                    callback?.onCustomViewHidden()
+                } else {
+                    customView = view
+                    customViewCallback = callback
+                    activity?.keepScreenOn(true)
+                    activity?.toggleSystemBar(false)
+                }
+            },
+            onHideCustomView = { hideCustomView() },
+            navigateToArticles = { sortUrl, targetOrigin ->
+                onOpenArticles(sortUrl, targetOrigin)
+            },
+            onAskRedirect = { from, to, onResult ->
+                redirectRequest = RedirectRequest(from, to, onResult)
+            },
+            onCloseRequested = onBackClick,
+            isFullscreenProvider = { customView != null },
+        )
+    }
+
     LaunchedEffect(origin, link, openUrl, title, startPage) {
     LaunchedEffect(origin, link, openUrl, title, startPage) {
         viewModel.initData(
         viewModel.initData(
             ReadRssArgs(
             ReadRssArgs(
@@ -147,6 +184,16 @@ fun RssReadRouteScreen(
     LaunchedEffect(content, webView, fallbackUserAgent) {
     LaunchedEffect(content, webView, fallbackUserAgent) {
         val body = content ?: return@LaunchedEffect
         val body = content ?: return@LaunchedEffect
         val currentWebView = webView ?: return@LaunchedEffect
         val currentWebView = webView ?: return@LaunchedEffect
+        // preloadJs 所需的 java/source/cache 接口必须在页面加载前注入,
+        // 时序上要等到 initData 完成(hasPreloadJs 确定)之后,否则 startJs 首行会中断
+        injectRssReadJsInterfaces(
+            webView = currentWebView,
+            viewModel = viewModel,
+            appCompatActivity = appCompatActivity,
+            callbacks = controllerCallbacks,
+            injectedSourceUrl = injectedJsSourceUrl,
+            onInjected = { injectedJsSourceUrl = it },
+        )
         currentWebView.settings.userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: fallbackUserAgent
         currentWebView.settings.userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: fallbackUserAgent
         val article = viewModel.rssArticle
         val article = viewModel.rssArticle
         val url = article?.let {
         val url = article?.let {
@@ -181,7 +228,47 @@ fun RssReadRouteScreen(
     BackHandler {
     BackHandler {
         when {
         when {
             customView != null -> customViewCallback?.onCustomViewHidden() ?: hideCustomView()
             customView != null -> customViewCallback?.onCustomViewHidden() ?: hideCustomView()
-            webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size ?: 0) > 1 -> webView?.goBack()
+            webView?.canGoBack() == true -> {
+                val list = webView?.copyBackForwardList() ?: return@BackHandler
+                val size = list.size
+                if (size == 1) {
+                    onBackClick()
+                    return@BackHandler
+                }
+                val currentIndex = list.currentIndex
+                val currentItem = list.currentItem
+                val currentUrl = currentItem?.originalUrl ?: BLANK_HTML
+                val currentTitle = currentItem?.title
+                //从后往前找,找到第一个不同链接的页面,计算需要回退多少步 避免刷新后导致返回不灵
+                var steps = 1
+                for (i in currentIndex - 1 downTo 0) {
+                    val item = list.getItemAtIndex(i)
+                    val itemTitle = item.title
+                    val index = refreshNameList.indexOf(itemTitle)
+                    if (index != -1) {
+                        refreshNameList.removeAt(index)
+                        steps++
+                        continue
+                    }
+                    val itemUrl = item.originalUrl
+                    if (itemUrl == BLANK_HTML) {
+                        onBackClick()
+                        return@BackHandler
+                    }
+                    if (itemUrl != currentUrl || itemTitle != currentTitle) {
+                        break
+                    }
+                    if (currentUrl == DATA_HTML) {
+                        break
+                    }
+                    steps++
+                }
+                if (steps == size) {
+                    onBackClick()
+                    return@BackHandler
+                }
+                webView?.goBackOrForward(-steps)
+            }
             else -> onBackClick()
             else -> onBackClick()
         }
         }
     }
     }
@@ -213,7 +300,14 @@ fun RssReadRouteScreen(
                         TopBarActionButton(
                         TopBarActionButton(
                             imageVector = Icons.Default.Refresh,
                             imageVector = Icons.Default.Refresh,
                             contentDescription = stringResource(R.string.refresh),
                             contentDescription = stringResource(R.string.refresh),
-                            onClick = { viewModel.refresh { webView?.reload() } }
+                            onClick = {
+                                if (viewModel.rssSource?.singleUrl == true) {
+                                    webView?.reload()
+                                } else {
+                                    webView?.title?.let { refreshNameList.add(it) }
+                                    viewModel.refresh { webView?.reload() }
+                                }
+                            }
                         )
                         )
                         if (!startPage) {
                         if (!startPage) {
                             TopBarActionButton(
                             TopBarActionButton(
@@ -291,6 +385,13 @@ fun RssReadRouteScreen(
                                     text = stringResource(R.string.redirect_policy),
                                     text = stringResource(R.string.redirect_policy),
                                     onClick = { showRedirectMenu = true }
                                     onClick = { showRedirectMenu = true }
                                 )
                                 )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.log),
+                                    onClick = {
+                                        dismiss()
+                                        showLogSheet = true
+                                    }
+                                )
                                 RoundDropdownMenuItem(
                                 RoundDropdownMenuItem(
                                     text = stringResource(R.string.open_in_browser),
                                     text = stringResource(R.string.open_in_browser),
                                     leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
                                     leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
@@ -343,6 +444,7 @@ fun RssReadRouteScreen(
                         modifier = Modifier.fillMaxSize(),
                         modifier = Modifier.fillMaxSize(),
                         onCreated = { createdWebView ->
                         onCreated = { createdWebView ->
                             webView = createdWebView
                             webView = createdWebView
+                            injectedJsSourceUrl = null
                             configureRssReadWebView(
                             configureRssReadWebView(
                                 webView = createdWebView,
                                 webView = createdWebView,
                                 context = context,
                                 context = context,
@@ -350,28 +452,8 @@ fun RssReadRouteScreen(
                                 appCompatActivity = appCompatActivity,
                                 appCompatActivity = appCompatActivity,
                                 viewModel = viewModel,
                                 viewModel = viewModel,
                                 initialTitle = title,
                                 initialTitle = title,
-                                isStartPage = startPage,
                                 redirectPolicyProvider = { redirectPolicy },
                                 redirectPolicyProvider = { redirectPolicy },
-                                callbacks = RssReadWebControllerCallbacks(
-                                    onProgressChanged = { webProgress = it },
-                                    onPageTitleResolved = { resolved ->
-                                        pageTitle = resolved.ifBlank { defaultTopBarTitle }
-                                    },
-                                    onShowCustomView = { view, callback ->
-                                        if (view == null) {
-                                            callback?.onCustomViewHidden()
-                                        } else if (customView != null) {
-                                            callback?.onCustomViewHidden()
-                                        } else {
-                                            customView = view
-                                            customViewCallback = callback
-                                            activity?.keepScreenOn(true)
-                                            activity?.toggleSystemBar(false)
-                                        }
-                                    },
-                                    onHideCustomView = { hideCustomView() },
-                                    navigateToArticles = onOpenArticles
-                                )
+                                callbacks = controllerCallbacks,
                             )
                             )
                         }
                         }
                     )
                     )
@@ -417,8 +499,41 @@ fun RssReadRouteScreen(
             showFavoriteSheet = false
             showFavoriteSheet = false
         }
         }
     )
     )
+
+    redirectRequest?.let { request ->
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = {
+                request.onResult(false)
+                redirectRequest = null
+            },
+            title = "重定向请求",
+            text = "是否允许页面跳转?\n\n来源:${request.fromUrl ?: "未知"}\n目标:${request.toUrl}",
+            confirmText = "允许",
+            onConfirm = {
+                request.onResult(true)
+                redirectRequest = null
+            },
+            dismissText = "拒绝",
+            onDismiss = {
+                request.onResult(false)
+                redirectRequest = null
+            },
+        )
+    }
+
+    AppLogSheet(
+        show = showLogSheet,
+        onDismissRequest = { showLogSheet = false }
+    )
 }
 }
 
 
+private data class RedirectRequest(
+    val fromUrl: String?,
+    val toUrl: String,
+    val onResult: (Boolean) -> Unit,
+)
+
 @Composable
 @Composable
 private fun FavoriteEditSheet(
 private fun FavoriteEditSheet(
     show: Boolean,
     show: Boolean,
@@ -473,3 +588,6 @@ private fun FavoriteEditSheet(
         )
         )
     }
     }
 }
 }
+
+private const val BLANK_HTML = "about:blank"
+private const val DATA_HTML = "data:text/html;charset=utf-8;base64,"

+ 232 - 45
app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt

@@ -5,11 +5,14 @@ package io.legado.app.ui.rss.read
 import android.annotation.SuppressLint
 import android.annotation.SuppressLint
 import android.app.Activity
 import android.app.Activity
 import android.content.Context
 import android.content.Context
+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.SystemClock
 import android.os.SystemClock
 import android.view.View
 import android.view.View
+import android.webkit.ConsoleMessage
+import android.webkit.CookieManager
 import android.webkit.JavascriptInterface
 import android.webkit.JavascriptInterface
 import android.webkit.SslErrorHandler
 import android.webkit.SslErrorHandler
 import android.webkit.URLUtil
 import android.webkit.URLUtil
@@ -19,7 +22,6 @@ import android.webkit.WebResourceResponse
 import android.webkit.WebSettings
 import android.webkit.WebSettings
 import android.webkit.WebView
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import android.webkit.WebViewClient
-import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.graphics.createBitmap
 import androidx.core.graphics.createBitmap
 import androidx.core.net.toUri
 import androidx.core.net.toUri
@@ -28,8 +30,13 @@ import com.script.rhino.runScriptWithContext
 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.constant.AppLog
 import io.legado.app.constant.AppLog
+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.http.CookieStore
+import io.legado.app.help.http.newCallResponse
+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.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.lib.dialogs.selector
@@ -40,25 +47,35 @@ import io.legado.app.utils.isTrue
 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.setDarkeningAllowed
 import io.legado.app.utils.setDarkeningAllowed
+import io.legado.app.utils.splitNotBlank
 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 kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType
 import java.io.ByteArrayInputStream
 import java.io.ByteArrayInputStream
 import java.net.URLDecoder
 import java.net.URLDecoder
+import java.util.regex.PatternSyntaxException
 
 
 internal data class RssReadWebControllerCallbacks(
 internal data class RssReadWebControllerCallbacks(
     val onProgressChanged: (Int) -> Unit,
     val onProgressChanged: (Int) -> Unit,
     val onPageTitleResolved: (String) -> Unit,
     val onPageTitleResolved: (String) -> Unit,
     val onShowCustomView: (View?, WebChromeClient.CustomViewCallback?) -> Unit,
     val onShowCustomView: (View?, WebChromeClient.CustomViewCallback?) -> Unit,
     val onHideCustomView: () -> Unit,
     val onHideCustomView: () -> Unit,
-    val navigateToArticles: (String?) -> Unit
+    val navigateToArticles: (sortUrl: String?, origin: String?) -> Unit,
+    val onAskRedirect: (String?, String, (Boolean) -> Unit) -> Unit,
+    val onCloseRequested: () -> Unit,
+    val isFullscreenProvider: () -> Boolean,
 ) : WebJsExtensions.Callback {
 ) : WebJsExtensions.Callback {
     override fun upConfig(config: String) = Unit
     override fun upConfig(config: String) = Unit
 
 
-    override fun onNavigateToArticles(sortUrl: String?) {
-        navigateToArticles(sortUrl)
+    override fun onNavigateToArticles(sortUrl: String?, origin: String?) {
+        navigateToArticles(sortUrl, origin)
     }
     }
 }
 }
 
 
+private val webCookieManager: CookieManager by lazy { CookieManager.getInstance() }
+
 @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
 @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
 internal fun configureRssReadWebView(
 internal fun configureRssReadWebView(
     webView: VisibleWebView,
     webView: VisibleWebView,
@@ -67,7 +84,6 @@ internal fun configureRssReadWebView(
     appCompatActivity: AppCompatActivity?,
     appCompatActivity: AppCompatActivity?,
     viewModel: ReadRssViewModel,
     viewModel: ReadRssViewModel,
     initialTitle: String?,
     initialTitle: String?,
-    isStartPage: Boolean,
     redirectPolicyProvider: () -> RedirectPolicy,
     redirectPolicyProvider: () -> RedirectPolicy,
     callbacks: RssReadWebControllerCallbacks
     callbacks: RssReadWebControllerCallbacks
 ) {
 ) {
@@ -87,6 +103,26 @@ internal fun configureRssReadWebView(
         override fun onHideCustomView() {
         override fun onHideCustomView() {
             callbacks.onHideCustomView()
             callbacks.onHideCustomView()
         }
         }
+
+        override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
+            val source = viewModel.rssSource
+            if (source?.showWebLog == true) {
+                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
+            }
+            return false
+        }
+
+        override fun onCloseWindow(window: WebView?) {
+            callbacks.onCloseRequested()
+        }
     }
     }
 
 
     webView.webViewClient = object : WebViewClient() {
     webView.webViewClient = object : WebViewClient() {
@@ -123,31 +159,121 @@ internal fun configureRssReadWebView(
             view: WebView?,
             view: WebView?,
             request: WebResourceRequest
             request: WebResourceRequest
         ): WebResourceResponse? {
         ): WebResourceResponse? {
-            val source = viewModel.rssSource
-            val preloadJs = source?.preloadJs
-            if (
-                isStartPage &&
-                !preloadJsInjected &&
-                !preloadJs.isNullOrBlank() &&
-                request.url.toString() == WebJsExtensions.nameUrl
-            ) {
-                preloadJsInjected = true
-                val jsContent = buildString {
-                    append("(() => {")
-                    append(WebJsExtensions.JS_INJECTION)
-                    append("\n")
-                    append(preloadJs)
-                    append("\n})();")
+            val source = viewModel.rssSource ?: return super.shouldInterceptRequest(view, request)
+            val url = request.url.toString()
+            if (request.isForMainFrame) {
+                if (viewModel.hasPreloadJs) {
+                    preloadJsInjected = false
+                    if (url.startsWith("data:text/html;") || request.method == "POST") {
+                        return super.shouldInterceptRequest(view, request)
+                    }
+                    return runBlocking(IO) {
+                        getModifiedContentWithJs(url, request) ?: super.shouldInterceptRequest(
+                            view,
+                            request
+                        )
+                    }
                 }
                 }
+            } else if (!preloadJsInjected && url == WebJsExtensions.nameUrl) {
+                preloadJsInjected = true
+                val preloadJs = source.preloadJs ?: ""
                 return WebResourceResponse(
                 return WebResourceResponse(
                     "text/javascript",
                     "text/javascript",
                     "utf-8",
                     "utf-8",
-                    ByteArrayInputStream(jsContent.toByteArray())
+                    ByteArrayInputStream("(() => {${WebJsExtensions.JS_INJECTION}\n$preloadJs\n})();".toByteArray())
                 )
                 )
             }
             }
+            val blacklist = source.contentBlacklist?.splitNotBlank(",")
+            if (!blacklist.isNullOrEmpty()) {
+                blacklist.forEach {
+                    try {
+                        if (url.startsWith(it) || url.matches(it.toRegex())) {
+                            return createEmptyResource()
+                        }
+                    } catch (e: PatternSyntaxException) {
+                        AppLog.put("黑名单规则正则语法错误 源名称:${source.sourceName} 正则:$it", e)
+                    }
+                }
+            } else {
+                val whitelist = source.contentWhitelist?.splitNotBlank(",")
+                if (!whitelist.isNullOrEmpty()) {
+                    whitelist.forEach {
+                        try {
+                            if (url.startsWith(it) || url.matches(it.toRegex())) {
+                                return super.shouldInterceptRequest(view, request)
+                            }
+                        } catch (e: PatternSyntaxException) {
+                            AppLog.put(
+                                "白名单规则正则语法错误 源名称:${source.sourceName} 正则:$it",
+                                e
+                            )
+                        }
+                    }
+                    return createEmptyResource()
+                }
+            }
             return 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 { setCookie ->
+                    webCookieManager.setCookie(url, setCookie)
+                }
+                val body = res.body
+                val contentType = body.contentType()
+                if (!shouldInjectPreloadJs(contentType, res.header("Content-Disposition"))) {
+                    res.close()
+                    return null
+                }
+                val mimeType = contentType?.toString()?.substringBefore(";") ?: "text/html"
+                val charset = contentType?.charset() ?: Charsets.UTF_8
+                val charsetName = charset.name()
+                val bodyText = body.text().let { originalText ->
+                    val headIndex = originalText.indexOf("<head", ignoreCase = true)
+                    if (headIndex >= 0) {
+                        val closingHeadIndex = originalText.indexOf('>', startIndex = headIndex)
+                        if (closingHeadIndex >= 0) {
+                            val insertPos = closingHeadIndex + 1
+                            StringBuilder(originalText).insert(insertPos, WebJsExtensions.JS_URL)
+                                .toString()
+                        } else {
+                            originalText
+                        }
+                    } else {
+                        originalText
+                    }
+                }
+                WebResourceResponse(
+                    mimeType,
+                    charsetName,
+                    ByteArrayInputStream(bodyText.toByteArray(charset))
+                )
+            } catch (_: Exception) {
+                null
+            }
+        }
+
+        private fun createEmptyResource(): WebResourceResponse {
+            return WebResourceResponse(
+                "text/plain", "utf-8", ByteArrayInputStream("".toByteArray())
+            )
+        }
+
         private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
         private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
             val fromHost = fromUrl?.toUri()?.host
             val fromHost = fromUrl?.toUri()?.host
             val toHost = toUrl.toUri().host
             val toHost = toUrl.toUri().host
@@ -192,13 +318,7 @@ internal fun configureRssReadWebView(
         }
         }
 
 
         private fun askUser(fromUrl: String?, toUrl: String, onResult: (Boolean) -> Unit) {
         private fun askUser(fromUrl: String?, toUrl: String, onResult: (Boolean) -> Unit) {
-            AlertDialog.Builder(context)
-                .setTitle("重定向请求")
-                .setMessage("是否允许页面跳转?\n\n来源:${fromUrl ?: "未知"}\n目标:$toUrl")
-                .setPositiveButton("允许") { _, _ -> onResult(true) }
-                .setNegativeButton("拒绝") { _, _ -> onResult(false) }
-                .setCancelable(true)
-                .show()
+            callbacks.onAskRedirect(fromUrl, toUrl, onResult)
         }
         }
 
 
         private fun handleCustomScheme(url: Uri): Boolean {
         private fun handleCustomScheme(url: Uri): Boolean {
@@ -245,8 +365,18 @@ internal fun configureRssReadWebView(
             }
             }
         }
         }
 
 
+        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+            view?.evaluateJavascript(WebJsExtensions.basicJs, null)
+        }
+
         override fun onPageFinished(view: WebView, url: String?) {
         override fun onPageFinished(view: WebView, url: String?) {
             super.onPageFinished(view, url)
             super.onPageFinished(view, url)
+            val finishedUrl = url ?: return
+            if (URLUtil.isNetworkUrl(finishedUrl)) {
+                webCookieManager.getCookie(finishedUrl)?.takeIf { it.isNotBlank() }?.let { cookie ->
+                    CookieStore.setCookie(finishedUrl, cookie)
+                }
+            }
             view.applyDayNight(AppConfig.isNightTheme)
             view.applyDayNight(AppConfig.isNightTheme)
             view.title?.let { webTitle ->
             view.title?.let { webTitle ->
                 if (
                 if (
@@ -296,28 +426,41 @@ internal fun configureRssReadWebView(
         }
         }
     }
     }
 
 
-    val source = viewModel.rssSource
-    if (
-        isStartPage &&
-        !source?.preloadJs.isNullOrBlank() &&
-        appCompatActivity != null
-    ) {
-        val webJsExtensions = WebJsExtensions(
-            source,
-            appCompatActivity,
-            webView,
-            callback = callbacks
-        )
-        webView.addJavascriptInterface(webJsExtensions, WebJsExtensions.nameJava)
-        webView.addJavascriptInterface(source, WebJsExtensions.nameSource)
-        webView.addJavascriptInterface(WebCacheManager, WebJsExtensions.nameCache)
-    }
-
     webView.addJavascriptInterface(object {
     webView.addJavascriptInterface(object {
         @JavascriptInterface
         @JavascriptInterface
         fun isNightTheme(): Boolean = AppConfig.isNightTheme
         fun isNightTheme(): Boolean = AppConfig.isNightTheme
     }, "thisActivity")
     }, "thisActivity")
 
 
+    webView.addJavascriptInterface(object {
+        @JavascriptInterface
+        fun lockOrientation(orientation: String) {
+            val ctx = activity
+            if (ctx != null && callbacks.isFullscreenProvider() && !ctx.isFinishing && !ctx.isDestroyed) {
+                ctx.runOnUiThread {
+                    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
+                    }
+                }
+            }
+        }
+
+        @JavascriptInterface
+        fun onCloseRequested() {
+            val ctx = activity
+            if (ctx != null && !ctx.isFinishing && !ctx.isDestroyed) {
+                ctx.runOnUiThread {
+                    callbacks.onCloseRequested()
+                }
+            }
+        }
+    }, WebJsExtensions.nameBasic)
+
     webView.setOnLongClickListener {
     webView.setOnLongClickListener {
         val hitTestResult = webView.hitTestResult
         val hitTestResult = webView.hitTestResult
         if (
         if (
@@ -351,3 +494,47 @@ internal fun configureRssReadWebView(
         }
         }
     }
     }
 }
 }
+
+private fun shouldInjectPreloadJs(
+    contentType: MediaType?,
+    contentDisposition: String?
+): Boolean {
+    if (contentDisposition
+            ?.substringBefore(';')
+            ?.trim()
+            ?.equals("attachment", ignoreCase = true) == true
+    ) return false
+    return contentType == null ||
+            contentType.type == "text" && contentType.subtype == "html" ||
+            contentType.type == "application" && contentType.subtype == "xhtml+xml"
+}
+
+/**
+ * 在页面内容加载前注入 preloadJs 所需的 JS 接口(java/source/cache)。
+ * 必须在 [ReadRssViewModel.hasPreloadJs] 确定后调用,否则接口会因时序缺失而
+ * 导致 startJs 首行(依赖 window.cache 等)抛错,进而在 let 声明前中断脚本。
+ */
+@SuppressLint("JavascriptInterface")
+internal fun injectRssReadJsInterfaces(
+    webView: WebView,
+    viewModel: ReadRssViewModel,
+    appCompatActivity: AppCompatActivity?,
+    callbacks: RssReadWebControllerCallbacks,
+    injectedSourceUrl: String? = null,
+    onInjected: (String) -> Unit = {}
+) {
+    val source = viewModel.rssSource ?: return
+    if (injectedSourceUrl == source.sourceUrl) return
+    if (!viewModel.hasPreloadJs || source.preloadJs.isNullOrBlank()) return
+    if (appCompatActivity == null) return
+    val webJsExtensions = WebJsExtensions(
+        source,
+        appCompatActivity,
+        webView,
+        callback = callbacks
+    )
+    webView.addJavascriptInterface(webJsExtensions, WebJsExtensions.nameJava)
+    webView.addJavascriptInterface(source, WebJsExtensions.nameSource)
+    webView.addJavascriptInterface(WebCacheManager, WebJsExtensions.nameCache)
+    onInjected(source.sourceUrl)
+}