Răsfoiți Sursa

[优化] 重写订阅阅读页面,可能有bug喵

HapeLee 6 luni în urmă
părinte
comite
ca70dcaec9
44 a modificat fișierele cu 2286 adăugiri și 2442 ștergeri
  1. 0 12
      app/src/main/AndroidManifest.xml
  2. 12 4
      app/src/main/java/io/legado/app/data/entities/BaseSource.kt
  3. 7 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 59 4
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  5. 7 2
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  6. 11 10
      app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt
  7. 0 16
      app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt
  8. 12 0
      app/src/main/java/io/legado/app/ui/rss/article/MainRouteRssSort.kt
  9. 0 88
      app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt
  10. 0 91
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt
  11. 0 90
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt
  12. 0 91
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt
  13. 0 91
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt
  14. 515 0
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt
  15. 46 134
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt
  16. 85 27
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt
  17. 0 265
      app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt
  18. 189 0
      app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt
  19. 364 0
      app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt
  20. 17 10
      app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt
  21. 0 91
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt
  22. 0 67
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt
  23. 9 6
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
  24. 86 0
      app/src/main/java/io/legado/app/ui/rss/navigation/RssMainNavContract.kt
  25. 12 0
      app/src/main/java/io/legado/app/ui/rss/read/MainRouteRssRead.kt
  26. 0 627
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt
  27. 91 67
      app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt
  28. 27 0
      app/src/main/java/io/legado/app/ui/rss/read/RedirectPolicy.kt
  29. 16 3
      app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt
  30. 400 0
      app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt
  31. 267 0
      app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt
  32. 54 28
      app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt
  33. 0 92
      app/src/main/res/layout/activity_rss_artivles.xml
  34. 0 41
      app/src/main/res/layout/activity_rss_read.xml
  35. 0 104
      app/src/main/res/layout/dialog_rss_favorite_config.xml
  36. 0 14
      app/src/main/res/layout/fragment_rss_articles.xml
  37. 0 57
      app/src/main/res/layout/item_rss_article.xml
  38. 0 56
      app/src/main/res/layout/item_rss_article_1.xml
  39. 0 58
      app/src/main/res/layout/item_rss_article_2.xml
  40. 0 65
      app/src/main/res/layout/item_rss_article_3.xml
  41. 0 23
      app/src/main/res/menu/menu_redirect_policy_submenu.xml
  42. 0 45
      app/src/main/res/menu/rss_articles.xml
  43. 0 51
      app/src/main/res/menu/rss_read.xml
  44. 0 12
      app/src/main/res/menu/rss_read_record.xml

+ 0 - 12
app/src/main/AndroidManifest.xml

@@ -263,18 +263,6 @@
             android:name=".ui.book.searchContent.SearchContentActivity"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
-        <!-- RSS条目 -->
-        <activity
-            android:name=".ui.rss.article.RssSortActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:launchMode="singleTop" />
-        <!-- RSS阅读 -->
-        <activity
-            android:name=".ui.rss.read.ReadRssActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
-            android:hardwareAccelerated="true"
-            android:launchMode="singleTop" />
         <!-- 发现界面 -->
         <activity
             android:name=".ui.book.explore.ExploreShowActivity"

+ 12 - 4
app/src/main/java/io/legado/app/data/entities/BaseSource.kt

@@ -105,13 +105,16 @@ interface BaseSource : JsExtensions {
         header?.let {
             try {
                 val json = when {
-                    it.startsWith("@js:", true) -> evalJS(it.substring(4)).toString()
+                    it.startsWith("@js:", true) -> evalJS(it.substring(4))?.toString().orEmpty()
                     it.startsWith("<js>", true) -> evalJS(
                         it.substring(4, it.lastIndexOf("<"))
-                    ).toString()
+                    )?.toString().orEmpty()
 
                     else -> it
                 }
+                if (json.isBlank()) {
+                    return@let
+                }
                 GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
                     putAll(map)
                 } ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
@@ -119,7 +122,12 @@ interface BaseSource : JsExtensions {
                     putAll(map)
                 }
             } catch (e: Exception) {
-                AppLog.put("执行请求头规则出错\n$e", e)
+                val isEmptyJsonNoise = e.message?.contains("Empty JSON string", ignoreCase = true) == true ||
+                    e.toString().contains("Empty JSON string", ignoreCase = true) ||
+                    e.stackTraceToString().contains("Empty JSON string", ignoreCase = true)
+                if (!isEmptyJsonNoise) {
+                    AppLog.put("执行请求头规则出错\n$e", e)
+                }
             }
         }
         if (!has(AppConst.UA_NAME, true)) {
@@ -341,4 +349,4 @@ interface BaseSource : JsExtensions {
         }
         return RhinoScriptEngine.eval(jsStr, scope)
     }
-}
+}

+ 7 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -51,6 +51,9 @@ import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleViewModel
 import io.legado.app.ui.replace.edit.ReplaceEditViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceViewModel
+import io.legado.app.ui.rss.article.RssArticlesViewModel
+import io.legado.app.ui.rss.article.RssSortViewModel
+import io.legado.app.ui.rss.read.ReadRssViewModel
 import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
@@ -92,6 +95,9 @@ val appModule = module {
 
     viewModelOf(::DictRuleViewModel)
     viewModelOf(::RssSourceViewModel)
+    viewModelOf(::RssSortViewModel)
+    viewModelOf(::RssArticlesViewModel)
+    viewModelOf(::ReadRssViewModel)
     viewModelOf(::ReadRecordViewModel)
     viewModelOf(::ExploreShowViewModel)
     viewModelOf(::MyViewModel)
@@ -127,3 +133,4 @@ val appModule = module {
 
     viewModelOf(::SearchContentViewModel)
 }
+

+ 59 - 4
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -48,6 +48,11 @@ import io.legado.app.ui.config.mainConfig.MainConfig
 import io.legado.app.ui.config.otherConfig.OtherConfigScreen
 import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
+import io.legado.app.ui.rss.article.MainRouteRssSort
+import io.legado.app.ui.rss.navigation.RssMainNavContract
+import io.legado.app.ui.rss.article.RssSortRouteScreen
+import io.legado.app.ui.rss.read.MainRouteRssRead
+import io.legado.app.ui.rss.read.RssReadRouteScreen
 import io.legado.app.ui.welcome.WelcomeActivity
 import io.legado.app.ui.widget.dialog.TextDialog
 import io.legado.app.utils.getPrefBoolean
@@ -98,7 +103,7 @@ open class MainActivity : BaseComposeActivity() {
     }
 
     private val viewModel by viewModel<MainViewModel>()
-    private val routeEvents = MutableSharedFlow<MainRoute>(extraBufferCapacity = 1)
+    private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
 
     @Serializable
     private sealed interface MainRoute : NavKey
@@ -276,6 +281,16 @@ open class MainActivity : BaseComposeActivity() {
                         },
                         onNavigateToLocalImport = {
                             navigateToRoute(backStack, MainRouteImportLocal)
+                        },
+                        onNavigateToRssSort = { sourceUrl, sortUrl, key ->
+                            navigateToRoute(
+                                backStack,
+                                MainRouteRssSort(
+                                    sourceUrl = sourceUrl,
+                                    sortUrl = sortUrl,
+                                    key = key
+                                )
+                            )
                         }
                     )
                 }
@@ -322,11 +337,29 @@ open class MainActivity : BaseComposeActivity() {
                         onBackClick = { navigateBack(backStack) }
                     )
                 }
+
+                entry<MainRouteRssSort> { route ->
+                    RssSortRouteScreen(
+                        sourceUrl = route.sourceUrl,
+                        initialSortUrl = route.sortUrl,
+                        onBackClick = { navigateBack(backStack) }
+                    )
+                }
+
+                entry<MainRouteRssRead> { route ->
+                    RssReadRouteScreen(
+                        title = route.title,
+                        origin = route.origin,
+                        link = route.link,
+                        openUrl = route.openUrl,
+                        onBackClick = { navigateBack(backStack) }
+                    )
+                }
             }
         )
     }
 
-    private fun navigateToRoute(backStack: MutableList<NavKey>, route: MainRoute) {
+    private fun navigateToRoute(backStack: MutableList<NavKey>, route: NavKey) {
         val currentRoute = backStack.lastOrNull()
         if (currentRoute == route) return
 
@@ -367,6 +400,26 @@ open class MainActivity : BaseComposeActivity() {
                     backStack.add(route)
                 }
             }
+
+            is MainRouteRssSort -> {
+                if (currentRoute == MainRouteHome) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
+
+            is MainRouteRssRead -> {
+                if (currentRoute == MainRouteHome) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
         }
     }
 
@@ -488,8 +541,10 @@ open class MainActivity : BaseComposeActivity() {
         }
     }
 
-    private fun resolveStartRoute(intent: Intent?): MainRoute {
-        return resolveStartRoute(intent?.getStringExtra(EXTRA_START_ROUTE))
+    private fun resolveStartRoute(intent: Intent?): NavKey {
+        val route = intent?.getStringExtra(EXTRA_START_ROUTE)
+        RssMainNavContract.resolveStartRoute(route, intent)?.let { return it }
+        return resolveStartRoute(route)
     }
 
     private fun resolveStartRoute(route: String?): MainRoute {

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

@@ -91,7 +91,8 @@ fun MainScreen(
     useRail: Boolean,
     onOpenSettings: () -> Unit,
     onNavigateToRemoteImport: () -> Unit,
-    onNavigateToLocalImport: () -> Unit
+    onNavigateToLocalImport: () -> Unit,
+    onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit
 ) {
     val context = LocalContext.current
     val coroutineScope = rememberCoroutineScope()
@@ -380,7 +381,11 @@ fun MainScreen(
                         )
 
                         MainDestination.Explore -> ExploreScreen()
-                        MainDestination.Rss -> RssScreen()
+                        MainDestination.Rss -> RssScreen(
+                            onOpenSort = { sourceUrl, sortUrl, key ->
+                                onNavigateToRssSort(sourceUrl, sortUrl, key)
+                            }
+                        )
                         MainDestination.My -> MyScreen(
                             viewModel = koinViewModel(),
                             onOpenSettings = onOpenSettings,

+ 11 - 10
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -48,9 +48,8 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.data.entities.RssSource
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.rss.article.RssSortActivity
+import io.legado.app.ui.rss.navigation.RssMainNavContract
 import io.legado.app.ui.rss.favorites.RssFavoritesActivity
-import io.legado.app.ui.rss.read.ReadRssActivity
 import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
 import io.legado.app.ui.rss.source.manage.RssSourceActivity
 import io.legado.app.ui.rss.subscription.RuleSubActivity
@@ -73,7 +72,8 @@ import org.koin.androidx.compose.koinViewModel
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun RssScreen(
-    viewModel: RssViewModel = koinViewModel()
+    viewModel: RssViewModel = koinViewModel(),
+    onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit
 ) {
     val context = LocalContext.current
     val uiState by viewModel.uiState.collectAsState()
@@ -83,18 +83,19 @@ fun RssScreen(
         if (rssSource.singleUrl) {
             viewModel.getSingleUrl(rssSource) { url ->
                 if (url.startsWith("http", true)) {
-                    context.startActivity<ReadRssActivity> {
-                        putExtra("title", rssSource.sourceName)
-                        putExtra("origin", url)
-                    }
+                    context.startActivity(
+                        RssMainNavContract.createRssReadIntent(
+                            context = context,
+                            title = rssSource.sourceName,
+                            origin = url
+                        )
+                    )
                 } else {
                     context.openUrl(url)
                 }
             }
         } else {
-            context.startActivity<RssSortActivity> {
-                putExtra("url", rssSource.sourceUrl)
-            }
+            onOpenSort(rssSource.sourceUrl, null, null)
         }
     }
 

+ 0 - 16
app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt

@@ -1,16 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.content.Context
-import androidx.viewbinding.ViewBinding
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.RssArticle
-
-
-abstract class BaseRssArticlesAdapter<VB : ViewBinding>(context: Context, val callBack: CallBack) :
-    RecyclerAdapter<RssArticle, VB>(context) {
-
-    interface CallBack {
-        val isGridLayout: Boolean
-        fun readRss(rssArticle: RssArticle)
-    }
-}

+ 12 - 0
app/src/main/java/io/legado/app/ui/rss/article/MainRouteRssSort.kt

@@ -0,0 +1,12 @@
+package io.legado.app.ui.rss.article
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MainRouteRssSort(
+    val sourceUrl: String,
+    val sortUrl: String? = null,
+    val key: String? = null
+) : NavKey
+

+ 0 - 88
app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt

@@ -1,88 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.content.Context
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.viewModels
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.BaseDialogFragment
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.RssReadRecord
-import io.legado.app.databinding.DialogRecyclerViewBinding
-import io.legado.app.databinding.ItemRssReadRecordBinding
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.utils.setLayout
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-
-class ReadRecordDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
-    Toolbar.OnMenuItemClickListener {
-
-    private val viewModel by viewModels<RssSortViewModel>()
-    private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val adapter by lazy {
-        ReadRecordAdapter(requireContext())
-    }
-
-    override fun onStart() {
-        super.onStart()
-        setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        binding.run {
-            //toolBar.setBackgroundColor(primaryColor)
-            toolBar.setTitle(R.string.read_record)
-            toolBar.inflateMenu(R.menu.rss_read_record)
-            toolBar.setOnMenuItemClickListener(this@ReadRecordDialog)
-            recyclerView.layoutManager = LinearLayoutManager(requireContext())
-            recyclerView.adapter = adapter
-        }
-        adapter.setItems(viewModel.getRecords())
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_clear -> {
-                alert(R.string.draw) {
-                    val countRead = viewModel.countRecords()
-                    setMessage(getString(R.string.sure_del) + "\n" + countRead + " " + getString(R.string.read_record))
-                    noButton()
-                    yesButton {
-                        viewModel.deleteAllRecord()
-                        adapter.clearItems()
-                    }
-                }
-            }
-        }
-        return true
-    }
-
-    inner class ReadRecordAdapter(context: Context) :
-        RecyclerAdapter<RssReadRecord, ItemRssReadRecordBinding>(context) {
-
-        override fun getViewBinding(parent: ViewGroup): ItemRssReadRecordBinding {
-            return ItemRssReadRecordBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemRssReadRecordBinding,
-            item: RssReadRecord,
-            payloads: MutableList<Any>
-        ) {
-            binding.textTitle.text = item.title
-            binding.textRecord.text = item.record
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemRssReadRecordBinding) {
-        }
-
-    }
-
-}

+ 0 - 91
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt

@@ -1,91 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.ViewGroup
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.RequestOptions
-import com.bumptech.glide.request.target.Target
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.ItemRssArticleBinding
-import io.legado.app.help.glide.ImageLoader
-import io.legado.app.help.glide.OkHttpModelLoader
-import io.legado.app.utils.gone
-import io.legado.app.utils.themeColor
-import io.legado.app.utils.visible
-
-
-class RssArticlesAdapter(context: Context, callBack: CallBack) :
-    BaseRssArticlesAdapter<ItemRssArticleBinding>(context, callBack) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding {
-        return ItemRssArticleBinding.inflate(inflater, parent, false)
-    }
-
-    @SuppressLint("CheckResult")
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemRssArticleBinding,
-        item: RssArticle,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            tvTitle.text = item.title
-            tvPubDate.text = item.pubDate
-            if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
-                imageView.gone()
-            } else {
-                val options =
-                    RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
-                ImageLoader.load(context, item.image).apply(options).apply {
-                    if (callBack.isGridLayout) {
-                        placeholder(R.drawable.image_rss_article)
-                    } else {
-                        addListener(object : RequestListener<Drawable> {
-                            override fun onLoadFailed(
-                                e: GlideException?,
-                                model: Any?,
-                                target: Target<Drawable>,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.gone()
-                                return false
-                            }
-
-                            override fun onResourceReady(
-                                resource: Drawable,
-                                model: Any,
-                                target: Target<Drawable>?,
-                                dataSource: DataSource,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.visible()
-                                return false
-                            }
-
-                        })
-                    }
-                }.into(imageView)
-            }
-            if (item.read) {
-                tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant))
-            } else {
-                tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
-        holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.readRss(it)
-            }
-        }
-    }
-
-}

+ 0 - 90
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt

@@ -1,90 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.ViewGroup
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.RequestOptions
-import com.bumptech.glide.request.target.Target
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.ItemRssArticle1Binding
-import io.legado.app.help.glide.ImageLoader
-import io.legado.app.help.glide.OkHttpModelLoader
-import io.legado.app.utils.gone
-import io.legado.app.utils.visible
-
-
-class RssArticlesAdapter1(context: Context, callBack: CallBack) :
-    BaseRssArticlesAdapter<ItemRssArticle1Binding>(context, callBack) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemRssArticle1Binding {
-        return ItemRssArticle1Binding.inflate(inflater, parent, false)
-    }
-
-    @SuppressLint("CheckResult")
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemRssArticle1Binding,
-        item: RssArticle,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            tvTitle.text = item.title
-            tvPubDate.text = item.pubDate
-            if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
-                imageView.gone()
-            } else {
-                val options =
-                    RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
-                ImageLoader.load(context, item.image).apply(options).apply {
-                    if (callBack.isGridLayout) {
-                        placeholder(R.drawable.image_rss_article)
-                    } else {
-                        addListener(object : RequestListener<Drawable> {
-                            override fun onLoadFailed(
-                                e: GlideException?,
-                                model: Any?,
-                                target: Target<Drawable>,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.gone()
-                                return false
-                            }
-
-                            override fun onResourceReady(
-                                resource: Drawable,
-                                model: Any,
-                                target: Target<Drawable>?,
-                                dataSource: DataSource,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.visible()
-                                return false
-                            }
-
-                        })
-                    }
-                }.into(imageView)
-            }
-//            if (item.read) {
-//                tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary))
-//            } else {
-//                tvTitle.setTextColor(context.getCompatColor(R.color.primaryText))
-//            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle1Binding) {
-        holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.readRss(it)
-            }
-        }
-    }
-
-}

+ 0 - 91
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt

@@ -1,91 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.ViewGroup
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.RequestOptions
-import com.bumptech.glide.request.target.Target
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.ItemRssArticle2Binding
-import io.legado.app.help.glide.ImageLoader
-import io.legado.app.help.glide.OkHttpModelLoader
-import io.legado.app.utils.gone
-import io.legado.app.utils.themeColor
-import io.legado.app.utils.visible
-
-
-class RssArticlesAdapter2(context: Context, callBack: CallBack) :
-    BaseRssArticlesAdapter<ItemRssArticle2Binding>(context, callBack) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemRssArticle2Binding {
-        return ItemRssArticle2Binding.inflate(inflater, parent, false)
-    }
-
-    @SuppressLint("CheckResult")
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemRssArticle2Binding,
-        item: RssArticle,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            tvTitle.text = item.title
-            tvPubDate.text = item.pubDate
-            if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
-                imageView.gone()
-            } else {
-                val options =
-                    RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
-                ImageLoader.load(context, item.image).apply(options).apply {
-                    if (callBack.isGridLayout) {
-                        placeholder(R.drawable.image_rss_article)
-                    } else {
-                        addListener(object : RequestListener<Drawable> {
-                            override fun onLoadFailed(
-                                e: GlideException?,
-                                model: Any?,
-                                target: Target<Drawable>,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.gone()
-                                return false
-                            }
-
-                            override fun onResourceReady(
-                                resource: Drawable,
-                                model: Any,
-                                target: Target<Drawable>?,
-                                dataSource: DataSource,
-                                isFirstResource: Boolean
-                            ): Boolean {
-                                imageView.visible()
-                                return false
-                            }
-
-                        })
-                    }
-                }.into(imageView)
-            }
-            if (item.read) {
-                tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant))
-            } else {
-                tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle2Binding) {
-        holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.readRss(it)
-            }
-        }
-    }
-
-}

+ 0 - 91
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt

@@ -1,91 +0,0 @@
-package io.legado.app.ui.rss.article
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.ViewGroup
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.RequestOptions
-import com.bumptech.glide.request.target.Target
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.ItemRssArticle3Binding
-import io.legado.app.help.glide.ImageLoader
-import io.legado.app.help.glide.OkHttpModelLoader
-import io.legado.app.utils.gone
-import io.legado.app.utils.themeColor
-import io.legado.app.utils.visible
-
-class RssArticlesAdapter3(context: Context, callBack: CallBack) :
-    BaseRssArticlesAdapter<ItemRssArticle3Binding>(context, callBack) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemRssArticle3Binding {
-        return ItemRssArticle3Binding.inflate(inflater, parent, false)
-    }
-
-    @SuppressLint("CheckResult")
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemRssArticle3Binding,
-        item: RssArticle,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            tvTitle.text = item.title
-            tvPubDate.text = item.pubDate
-
-            val imageUrl = item.image
-            if (imageUrl.isNullOrBlank() && !callBack.isGridLayout) {
-                imageView.gone()
-            } else {
-                imageView.visible()
-                imageView.adjustViewBounds = true
-                val options = RequestOptions()
-                    .set(OkHttpModelLoader.sourceOriginOption, item.origin)
-
-                ImageLoader.load(context, imageUrl)
-                    .apply(options)
-                    .listener(object : RequestListener<Drawable> {
-                        override fun onLoadFailed(
-                            e: GlideException?,
-                            model: Any?,
-                            target: Target<Drawable>?,
-                            isFirstResource: Boolean
-                        ): Boolean {
-                            imageView.gone()
-                            return false
-                        }
-
-                        override fun onResourceReady(
-                            resource: Drawable,
-                            model: Any,
-                            target: Target<Drawable>?,
-                            dataSource: DataSource?,
-                            isFirstResource: Boolean
-                        ): Boolean {
-                            return false
-                        }
-                    })
-                    .into(imageView)
-            }
-
-            tvTitle.setTextColor(
-                if (item.read)
-                    context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant)
-                else
-                    context.themeColor(com.google.android.material.R.attr.colorOnSurface)
-            )
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle3Binding) {
-        holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.readRss(it)
-            }
-        }
-    }
-}

+ 515 - 0
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt

@@ -0,0 +1,515 @@
+package io.legado.app.ui.rss.article
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
+import androidx.compose.foundation.lazy.staggeredgrid.items
+import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.RssArticle
+import io.legado.app.data.entities.RssSource
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.flowOf
+import org.koin.compose.koinInject
+
+private enum class RssArticleLayout {
+    List, LargeCard, GridCard, Waterfall
+}
+
+private fun Int.toRssArticleLayout(): RssArticleLayout {
+    return when (this) {
+        1 -> RssArticleLayout.LargeCard
+        2 -> RssArticleLayout.GridCard
+        3 -> RssArticleLayout.Waterfall
+        else -> RssArticleLayout.List
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RssArticlesPage(
+    sortName: String,
+    sortUrl: String,
+    articleStyle: Int,
+    rssUrl: String?,
+    rssSource: RssSource?,
+    viewModel: RssArticlesViewModel,
+    onRead: (RssArticle) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val context = LocalContext.current
+    val layout = remember(articleStyle) { articleStyle.toRssArticleLayout() }
+    val loadState by viewModel.loadState.collectAsStateWithLifecycle()
+
+    LaunchedEffect(sortName, sortUrl) {
+        viewModel.init(sortName, sortUrl)
+    }
+
+    val articleFlow = remember(rssUrl, sortName) {
+        val origin = rssUrl.orEmpty()
+        if (origin.isBlank()) {
+            flowOf(emptyList())
+        } else {
+            appDb.rssArticleDao.flowByOriginSort(origin, sortName)
+        }.catch {
+            AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
+        }.flowOn(IO)
+    }
+    val articles by articleFlow.collectAsStateWithLifecycle(initialValue = emptyList())
+
+    LaunchedEffect(rssSource?.sourceUrl, sortName, sortUrl) {
+        rssSource?.let(viewModel::loadArticles)
+    }
+
+    LaunchedEffect(loadState.errorMessage) {
+        loadState.errorMessage?.takeIf { it.isNotBlank() }?.let(context::toastOnUi)
+    }
+
+    val refreshState = rememberPullToRefreshState()
+
+    PullToRefreshBox(
+        isRefreshing = loadState.isRefreshing,
+        onRefresh = { rssSource?.let(viewModel::loadArticles) },
+        state = refreshState,
+        modifier = modifier.fillMaxSize()
+    ) {
+        when (layout) {
+            RssArticleLayout.List, RssArticleLayout.LargeCard -> {
+                val listState = rememberLazyListState()
+                LoadMoreDetector(
+                    state = listState,
+                    enabled = loadState.canLoadMore,
+                    onLoadMore = { rssSource?.let(viewModel::loadMore) }
+                )
+                LazyColumn(
+                    state = listState,
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    items(
+                        count = articles.size,
+                        key = { index -> articles[index].origin + articles[index].link }
+                    ) { index ->
+                        RssArticleItem(
+                            article = articles[index],
+                            layout = layout,
+                            onClick = onRead
+                        )
+                    }
+                    item {
+                        LoadMoreFooter(
+                            state = loadState,
+                            onRetry = { rssSource?.let(viewModel::loadMore) }
+                        )
+                    }
+                }
+            }
+
+            RssArticleLayout.GridCard -> {
+                val gridState = rememberLazyGridState()
+                GridLoadMoreDetector(
+                    state = gridState,
+                    enabled = loadState.canLoadMore,
+                    onLoadMore = { rssSource?.let(viewModel::loadMore) }
+                )
+                LazyVerticalGrid(
+                    columns = GridCells.Fixed(2),
+                    state = gridState,
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    items(
+                        items = articles,
+                        key = { it.origin + it.link }
+                    ) { article ->
+                        RssArticleItem(
+                            article = article,
+                            layout = layout,
+                            onClick = onRead
+                        )
+                    }
+                    item(span = { GridItemSpan(maxLineSpan) }) {
+                        LoadMoreFooter(
+                            state = loadState,
+                            onRetry = { rssSource?.let(viewModel::loadMore) }
+                        )
+                    }
+                }
+            }
+
+            RssArticleLayout.Waterfall -> {
+                val staggeredState = rememberLazyStaggeredGridState()
+                StaggeredLoadMoreDetector(
+                    state = staggeredState,
+                    enabled = loadState.canLoadMore,
+                    onLoadMore = { rssSource?.let(viewModel::loadMore) }
+                )
+                LazyVerticalStaggeredGrid(
+                    columns = StaggeredGridCells.Fixed(2),
+                    state = staggeredState,
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    verticalItemSpacing = 8.dp
+                ) {
+                    items(
+                        items = articles,
+                        key = { it.origin + it.link }
+                    ) { article ->
+                        RssArticleItem(
+                            article = article,
+                            layout = layout,
+                            onClick = onRead
+                        )
+                    }
+                    item(span = StaggeredGridItemSpan.FullLine) {
+                        LoadMoreFooter(
+                            state = loadState,
+                            onRetry = { rssSource?.let(viewModel::loadMore) }
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun LoadMoreDetector(
+    state: LazyListState,
+    enabled: Boolean,
+    onLoadMore: () -> Unit
+) {
+    LaunchedEffect(state, enabled) {
+        snapshotFlow {
+            val info = state.layoutInfo
+            val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+        }.collect { shouldLoad ->
+            if (shouldLoad) onLoadMore()
+        }
+    }
+}
+
+@Composable
+private fun GridLoadMoreDetector(
+    state: LazyGridState,
+    enabled: Boolean,
+    onLoadMore: () -> Unit
+) {
+    LaunchedEffect(state, enabled) {
+        snapshotFlow {
+            val info = state.layoutInfo
+            val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+        }.collect { shouldLoad ->
+            if (shouldLoad) onLoadMore()
+        }
+    }
+}
+
+@Composable
+private fun StaggeredLoadMoreDetector(
+    state: LazyStaggeredGridState,
+    enabled: Boolean,
+    onLoadMore: () -> Unit
+) {
+    LaunchedEffect(state, enabled) {
+        snapshotFlow {
+            val info = state.layoutInfo
+            val lastVisible = info.visibleItemsInfo.maxOfOrNull { it.index } ?: -1
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+        }.collect { shouldLoad ->
+            if (shouldLoad) onLoadMore()
+        }
+    }
+}
+
+@Composable
+private fun LoadMoreFooter(
+    state: RssArticlesLoadState,
+    onRetry: () -> Unit
+) {
+    val text = when {
+        state.isLoadingMore -> "加载中..."
+        !state.hasMore -> "没有更多了"
+        state.errorMessage != null -> "加载失败,点击重试"
+        else -> "上拉加载更多"
+    }
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 12.dp),
+        contentAlignment = Alignment.Center
+    ) {
+        Text(
+            text = text,
+            style = LegadoTheme.typography.bodySmall,
+            color = LegadoTheme.colorScheme.onSurfaceVariant,
+            modifier = if (state.errorMessage != null) {
+                Modifier.clickable(onClick = onRetry)
+            } else {
+                Modifier
+            }
+        )
+    }
+}
+
+@Composable
+private fun RssArticleItem(
+    article: RssArticle,
+    layout: RssArticleLayout,
+    onClick: (RssArticle) -> Unit
+) {
+    val containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+    val titleColor = if (article.read) {
+        LegadoTheme.colorScheme.onSurfaceVariant
+    } else {
+        LegadoTheme.colorScheme.onSurface
+    }
+    val titleMaxLines = when (layout) {
+        RssArticleLayout.List -> 2
+        RssArticleLayout.LargeCard -> 2
+        RssArticleLayout.GridCard -> 2
+        RssArticleLayout.Waterfall -> 3
+    }
+
+    GlassCard(
+        onClick = { onClick(article) },
+        cornerRadius = 12.dp,
+        containerColor = containerColor,
+        modifier = Modifier.fillMaxWidth()
+    ) {
+        when (layout) {
+            RssArticleLayout.List -> {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(8.dp),
+                    verticalAlignment = Alignment.Top
+                ) {
+                    Column(modifier = Modifier.weight(1f)) {
+                        Text(
+                            text = article.title,
+                            style = LegadoTheme.typography.titleSmall,
+                            color = titleColor,
+                            maxLines = titleMaxLines,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                        Spacer(modifier = Modifier.height(8.dp))
+                        Text(
+                            text = article.pubDate.orEmpty(),
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                    }
+                    if (!article.image.isNullOrBlank()) {
+                        Spacer(modifier = Modifier.width(12.dp))
+                        RssArticleImage(
+                            article = article,
+                            showPlaceholder = false,
+                            modifier = Modifier
+                                .width(110.dp)
+                                .height(68.dp)
+                        )
+                    }
+                }
+            }
+
+            RssArticleLayout.LargeCard -> {
+                Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
+                    RssArticleImage(
+                        article = article,
+                        showPlaceholder = false,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .heightIn(min = 160.dp)
+                            .clip(RoundedCornerShape(8.dp))
+                    )
+                    Spacer(modifier = Modifier.height(8.dp))
+                    Text(
+                        text = article.title,
+                        style = LegadoTheme.typography.titleSmall,
+                        color = titleColor,
+                        maxLines = titleMaxLines,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                    Spacer(modifier = Modifier.height(4.dp))
+                    Text(
+                        text = article.pubDate.orEmpty(),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            }
+
+            RssArticleLayout.GridCard -> {
+                Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
+                    RssArticleImage(
+                        article = article,
+                        showPlaceholder = true,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .aspectRatio(3f / 4f)
+                            .clip(RoundedCornerShape(10.dp))
+                    )
+                    Spacer(modifier = Modifier.height(8.dp))
+                    Text(
+                        text = article.title,
+                        style = LegadoTheme.typography.bodyMedium,
+                        color = titleColor,
+                        maxLines = titleMaxLines,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                    Spacer(modifier = Modifier.height(4.dp))
+                    Text(
+                        text = article.pubDate.orEmpty(),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            }
+
+            RssArticleLayout.Waterfall -> {
+                Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
+                    RssArticleImage(
+                        article = article,
+                        showPlaceholder = false,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .background(LegadoTheme.colorScheme.surfaceContainer)
+                            .heightIn(min = 80.dp)
+                    )
+                    Column(modifier = Modifier.padding(horizontal = 8.dp)) {
+                        Spacer(modifier = Modifier.height(6.dp))
+                        Text(
+                            text = article.title,
+                            style = LegadoTheme.typography.titleSmall,
+                            color = titleColor,
+                            maxLines = titleMaxLines,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                        Spacer(modifier = Modifier.height(4.dp))
+                        Text(
+                            text = article.pubDate.orEmpty(),
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun RssArticleImage(
+    article: RssArticle,
+    showPlaceholder: Boolean,
+    modifier: Modifier = Modifier
+) {
+    val context = LocalContext.current
+    val imageLoader: ImageLoader = koinInject()
+    val imageUrl = article.image?.takeIf { it.isNotBlank() }
+
+    var hasError by remember(imageUrl) { mutableStateOf(false) }
+
+    if (imageUrl == null || (hasError && !showPlaceholder)) {
+        if (showPlaceholder) {
+            Box(modifier = modifier, contentAlignment = Alignment.Center) {
+                AsyncImage(
+                    model = R.drawable.image_rss_article,
+                    imageLoader = imageLoader,
+                    contentDescription = null,
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier.fillMaxSize()
+                )
+            }
+        }
+        return
+    }
+
+    AsyncImage(
+        model = buildCoverImageRequest(
+            context = context,
+            data = imageUrl,
+            sourceOrigin = article.origin,
+            loadOnlyWifi = false,
+            crossfade = true
+        ),
+        imageLoader = imageLoader,
+        contentDescription = null,
+        contentScale = ContentScale.Crop,
+        placeholder = if (showPlaceholder) painterResource(R.drawable.image_rss_article) else null,
+        error = if (showPlaceholder) painterResource(R.drawable.image_rss_article) else null,
+        onError = { hasError = true },
+        modifier = modifier
+    )
+}

+ 46 - 134
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt

@@ -1,40 +1,21 @@
 package io.legado.app.ui.rss.article
 
-
 import android.os.Bundle
+import android.view.LayoutInflater
 import android.view.View
-import androidx.fragment.app.activityViewModels
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import androidx.recyclerview.widget.StaggeredGridLayoutManager
-import io.legado.app.R
-import io.legado.app.base.VMBaseFragment
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
+import android.view.ViewGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.fragment.app.Fragment
 import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.FragmentRssArticlesBinding
-import io.legado.app.databinding.ViewLoadMoreBinding
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.rss.read.ReadRssActivity
-import io.legado.app.ui.widget.recycler.LoadMoreView
-import io.legado.app.utils.applyNavigationBarPadding
+import io.legado.app.ui.rss.navigation.RssMainNavContract
+import io.legado.app.ui.theme.AppTheme
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
 
-class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.fragment_rss_articles),
-    BaseRssArticlesAdapter.CallBack {
+class RssArticlesFragment() : Fragment() {
 
     constructor(sortName: String, sortUrl: String) : this() {
         arguments = Bundle().apply {
@@ -43,118 +24,49 @@ class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.frag
         }
     }
 
-    private val binding by viewBinding(FragmentRssArticlesBinding::bind)
-    private val activityViewModel by activityViewModels<RssSortViewModel>()
-    override val viewModel by viewModels<RssArticlesViewModel>()
-    private val adapter: BaseRssArticlesAdapter<*> by lazy {
-        when (activityViewModel.rssSource?.articleStyle) {
-            1 -> RssArticlesAdapter1(requireContext(), this@RssArticlesFragment)
-            2 -> RssArticlesAdapter2(requireContext(), this@RssArticlesFragment)
-            3 -> RssArticlesAdapter3(requireContext(), this@RssArticlesFragment)
-            else -> RssArticlesAdapter(requireContext(), this@RssArticlesFragment)
-        }
-    }
-    private val loadMoreView: LoadMoreView by lazy {
-        LoadMoreView(requireContext())
-    }
-    private var articlesFlowJob: Job? = null
-    override val isGridLayout: Boolean
-        get() = activityViewModel.isGridLayout
+    private val activityViewModel: RssSortViewModel by activityViewModel()
+    private val viewModel: RssArticlesViewModel by viewModel()
 
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        viewModel.init(arguments)
-        initView()
-        initData()
-    }
-
-    private fun initView() = binding.run {
-//        refreshLayout.setColorSchemeColors(accentColor)
-//        recyclerView.setEdgeEffectColor(primaryColor)
-        recyclerView.applyNavigationBarPadding()
-        loadMoreView.setOnClickListener {
-            if (!loadMoreView.isLoading) {
-                scrollToBottom(true)
-            }
-        }
-        val layoutManager = if (activityViewModel.isWaterLayout) {
-            recyclerView.itemAnimator = null
-            StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
-        } else if (activityViewModel.isGridLayout) {
-            GridLayoutManager(requireContext(), 2)
-        } else {
-            LinearLayoutManager(requireContext())
-        }
-        recyclerView.layoutManager = layoutManager
-        recyclerView.adapter = adapter
-        adapter.addFooterView {
-            ViewLoadMoreBinding.bind(loadMoreView)
-        }
-        refreshLayout.setOnRefreshListener {
-            loadArticles()
-        }
-        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
-            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
-                super.onScrolled(recyclerView, dx, dy)
-                if (!recyclerView.canScrollVertically(1)) {
-                    scrollToBottom()
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        return ComposeView(requireContext()).apply {
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                AppTheme {
+                    Content()
                 }
             }
-        })
-        viewLifecycleOwner.lifecycleScope.launch {
-            repeatOnLifecycle(Lifecycle.State.RESUMED) {
-                refreshLayout.isRefreshing = true
-                loadArticles()
-                this@launch.cancel()
-            }
-        }
-    }
-
-    private fun initData() {
-        val rssUrl = activityViewModel.url ?: return
-        articlesFlowJob?.cancel()
-        articlesFlowJob = viewLifecycleOwner.lifecycleScope.launch {
-            appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).catch {
-                AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
-            }.flowOn(IO).collect {
-                adapter.setItems(it)
-            }
         }
     }
 
-    private fun loadArticles() {
-        activityViewModel.rssSource?.let {
-            viewModel.loadArticles(it)
-        }
-    }
+    @Composable
+    private fun Content() {
+        val sortName = arguments?.getString("sortName").orEmpty()
+        val sortUrl = arguments?.getString("sortUrl").orEmpty()
 
-    private fun scrollToBottom(forceLoad: Boolean = false) {
-        if (viewModel.isLoading) return
-        if ((loadMoreView.hasMore && adapter.getActualItemCount() > 0) || forceLoad) {
-            loadMoreView.hasMore()
-            activityViewModel.rssSource?.let {
-                viewModel.loadMore(it)
-            }
-        }
+        RssArticlesPage(
+            sortName = sortName,
+            sortUrl = sortUrl,
+            articleStyle = activityViewModel.currentArticleStyle(),
+            rssUrl = activityViewModel.url,
+            rssSource = activityViewModel.rssSource,
+            viewModel = viewModel,
+            onRead = ::readRss
+        )
     }
 
-    override fun observeLiveBus() {
-        viewModel.loadErrorLiveData.observe(viewLifecycleOwner) {
-            loadMoreView.error(it)
-        }
-        viewModel.loadFinallyLiveData.observe(viewLifecycleOwner) { hasMore ->
-            binding.refreshLayout.isRefreshing = false
-            if (!hasMore) {
-                loadMoreView.noMore()
-            }
-        }
-    }
-
-    override fun readRss(rssArticle: RssArticle) {
+    private fun readRss(rssArticle: RssArticle) {
         activityViewModel.read(rssArticle)
-        startActivity<ReadRssActivity> {
-            putExtra("title", rssArticle.title)
-            putExtra("origin", rssArticle.origin)
-            putExtra("link", rssArticle.link)
-        }
+        requireContext().startActivity(
+            RssMainNavContract.createRssReadIntent(
+                context = requireContext(),
+                title = rssArticle.title,
+                origin = rssArticle.origin,
+                link = rssArticle.link
+            )
+        )
     }
-}
+}

+ 85 - 27
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt

@@ -2,7 +2,6 @@ package io.legado.app.ui.rss.article
 
 import android.app.Application
 import android.os.Bundle
-import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
@@ -12,12 +11,15 @@ import io.legado.app.data.entities.RssSource
 import io.legado.app.model.rss.Rss
 import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.Dispatchers.IO
-
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 
 class RssArticlesViewModel(application: Application) : BaseViewModel(application) {
-    val loadFinallyLiveData = MutableLiveData<Boolean>()
-    val loadErrorLiveData = MutableLiveData<String>()
-    var isLoading = true
+
+    private val _loadState = MutableStateFlow(RssArticlesLoadState())
+    val loadState: StateFlow<RssArticlesLoadState> = _loadState.asStateFlow()
+
     var order = System.currentTimeMillis()
     private var nextPageUrl: String? = null
     var sortName: String = ""
@@ -26,13 +28,29 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
 
     fun init(bundle: Bundle?) {
         bundle?.let {
-            sortName = it.getString("sortName") ?: ""
-            sortUrl = it.getString("sortUrl") ?: ""
+            init(
+                sortName = it.getString("sortName") ?: "",
+                sortUrl = it.getString("sortUrl") ?: ""
+            )
         }
     }
 
+    fun init(sortName: String, sortUrl: String) {
+        if (this.sortName == sortName && this.sortUrl == sortUrl) return
+        this.sortName = sortName
+        this.sortUrl = sortUrl
+        page = 1
+        nextPageUrl = null
+        _loadState.value = RssArticlesLoadState()
+    }
+
     fun loadArticles(rssSource: RssSource) {
-        isLoading = true
+        _loadState.value = _loadState.value.copy(
+            isRefreshing = true,
+            isLoadingMore = false,
+            errorMessage = null,
+            hasMore = true
+        )
         page = 1
         order = System.currentTimeMillis()
         Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) {
@@ -46,51 +64,91 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
                 appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order)
             }
             val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty()
-            loadFinallyLiveData.postValue(hasMore)
-            isLoading = false
+            _loadState.value = _loadState.value.copy(
+                isRefreshing = false,
+                isLoadingMore = false,
+                hasMore = hasMore,
+                errorMessage = null
+            )
         }.onError {
-            loadFinallyLiveData.postValue(false)
             AppLog.put("rss获取内容失败", it)
-            loadErrorLiveData.postValue(it.stackTraceStr)
+            _loadState.value = _loadState.value.copy(
+                isRefreshing = false,
+                isLoadingMore = false,
+                hasMore = false,
+                errorMessage = it.stackTraceStr
+            )
         }
     }
 
     fun loadMore(rssSource: RssSource) {
-        isLoading = true
-        page++
+        val currentState = _loadState.value
+        if (currentState.isRefreshing || currentState.isLoadingMore || !currentState.hasMore) return
+
         val pageUrl = nextPageUrl
         if (pageUrl.isNullOrEmpty()) {
-            loadFinallyLiveData.postValue(false)
+            _loadState.value = currentState.copy(hasMore = false)
             return
         }
+
+        _loadState.value = currentState.copy(
+            isLoadingMore = true,
+            errorMessage = null
+        )
+
+        page++
         Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
             nextPageUrl = it.second
             loadMoreSuccess(it.first)
-            isLoading = false
         }.onError {
-            loadFinallyLiveData.postValue(false)
             AppLog.put("rss获取内容失败", it)
-            loadErrorLiveData.postValue(it.stackTraceStr)
+            _loadState.value = _loadState.value.copy(
+                isLoadingMore = false,
+                errorMessage = it.stackTraceStr
+            )
         }
     }
 
     private fun loadMoreSuccess(articles: MutableList<RssArticle>) {
         if (articles.isEmpty()) {
-            loadFinallyLiveData.postValue(false)
+            _loadState.value = _loadState.value.copy(
+                isLoadingMore = false,
+                hasMore = false
+            )
             return
         }
         val firstArticle = articles.first()
         val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
         val lastArticle = articles.last()
         val dbLastArticle = appDb.rssArticleDao.getByLink(lastArticle.origin, lastArticle.link)
-        if (dbFirstArticle != null && dbLastArticle != null) {
-            loadFinallyLiveData.postValue(false)
-        } else {
-            articles.forEach {
-                it.order = order--
-            }
-            appDb.rssArticleDao.append(*articles.toTypedArray())
+
+        val shouldStop = dbFirstArticle != null && dbLastArticle != null
+        if (shouldStop) {
+            _loadState.value = _loadState.value.copy(
+                isLoadingMore = false,
+                hasMore = false
+            )
+            return
+        }
+
+        articles.forEach {
+            it.order = order--
         }
+        appDb.rssArticleDao.append(*articles.toTypedArray())
+
+        _loadState.value = _loadState.value.copy(
+            isLoadingMore = false,
+            hasMore = !nextPageUrl.isNullOrBlank()
+        )
     }
+}
 
-}
+data class RssArticlesLoadState(
+    val isRefreshing: Boolean = false,
+    val isLoadingMore: Boolean = false,
+    val hasMore: Boolean = true,
+    val errorMessage: String? = null
+) {
+    val canLoadMore: Boolean
+        get() = hasMore && !isRefreshing && !isLoadingMore
+}

+ 0 - 265
app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt

@@ -1,265 +0,0 @@
-@file:Suppress("DEPRECATION")
-
-package io.legado.app.ui.rss.article
-
-import android.content.Context
-import android.graphics.Color
-import android.graphics.drawable.GradientDrawable
-import android.os.Bundle
-import android.view.Gravity
-import android.view.Menu
-import android.view.MenuItem
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.lifecycleScope
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.google.android.material.tabs.TabLayoutMediator
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.databinding.ActivityRssArtivlesBinding
-import io.legado.app.help.source.sortUrls
-import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.rss.read.ReadRssActivity.RedirectPolicy
-import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
-import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.utils.StartActivityContract
-import io.legado.app.utils.gone
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.themeColor
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-class RssSortActivity : VMBaseActivity<ActivityRssArtivlesBinding, RssSortViewModel>(),
-    VariableDialog.Callback {
-
-    override val binding by viewBinding(ActivityRssArtivlesBinding::inflate)
-    override val viewModel by viewModels<RssSortViewModel>()
-
-    private var adapter: TabFragmentPageAdapter? = null
-    private val sortList = mutableListOf<Pair<String, String>>()
-    private val editSourceResult = registerForActivityResult(
-        StartActivityContract(RssSourceEditActivity::class.java)
-    ) {
-        if (it.resultCode == RESULT_OK) {
-            viewModel.initData(intent) {
-                upFragments()
-            }
-        }
-    }
-
-    var redirectPolicy: RedirectPolicy = RedirectPolicy.ALLOW_ALL
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        setSupportActionBar(binding.topBar)
-
-        viewModel.titleLiveData.observe(this) {
-            binding.ctlBar.title = it
-        }
-
-        viewModel.initData(intent) {
-            upFragments()
-            redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
-        }
-
-        binding.titleBar.addOnOffsetChangedListener({ appBarLayout, verticalOffset ->
-            if (-verticalOffset >= appBarLayout.totalScrollRange) {
-                val drawable = binding.tabRightFade.background as? GradientDrawable
-                drawable?.colors = intArrayOf(Color.TRANSPARENT,
-                    themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
-            } else {
-                val drawable = binding.tabRightFade.background as? GradientDrawable
-                drawable?.colors = intArrayOf(Color.TRANSPARENT,
-                    themeColor(com.google.android.material.R.attr.colorSurface))
-            }
-        })
-
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_articles, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible =
-            !viewModel.rssSource?.loginUrl.isNullOrBlank()
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_login -> startActivity<SourceLoginActivity> {
-                putExtra("type", "rssSource")
-                putExtra("key", viewModel.rssSource?.sourceUrl)
-            }
-
-            R.id.menu_refresh_sort -> viewModel.clearSortCache { upFragments() }
-
-            R.id.menu_set_source_variable -> setSourceVariable()
-
-            R.id.menu_edit_source -> viewModel.rssSource?.sourceUrl?.let {
-                editSourceResult.launch {
-                    putExtra("sourceUrl", it)
-                }
-            }
-            R.id.menu_redirect_policy ->
-                showRedirectPolicySubMenu()
-            R.id.menu_clear -> {
-                viewModel.url?.let {
-                    viewModel.clearArticles()
-                }
-            }
-
-            R.id.menu_switch_layout -> {
-                viewModel.switchLayout()
-                upFragments()
-            }
-
-            R.id.menu_read_record -> {
-                showDialogFragment<ReadRecordDialog>()
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun showRedirectPolicySubMenu() {
-        val popup = PopupMenu(this, binding.titleBar, Gravity.END)
-        popup.menuInflater.inflate(R.menu.menu_redirect_policy_submenu, popup.menu)
-        setCurrentRedirectPolicyChecked(popup.menu)
-        popup.setOnMenuItemClickListener { menuItem ->
-            handleRedirectPolicySelection(menuItem)
-            true
-        }
-        popup.show()
-    }
-
-    private fun setCurrentRedirectPolicyChecked(menu: Menu) {
-        val currentPolicy = redirectPolicy
-        val menuItemId = when (currentPolicy) {
-            RedirectPolicy.ALLOW_ALL -> R.id.menu_redirect_allow_all
-            RedirectPolicy.ASK_ALWAYS -> R.id.menu_redirect_ask_always
-            RedirectPolicy.ASK_CROSS_ORIGIN -> R.id.menu_redirect_ask_cross_origin
-            RedirectPolicy.BLOCK_CROSS_ORIGIN -> R.id.menu_redirect_block_cross_origin
-            RedirectPolicy.BLOCK_ALL -> R.id.menu_redirect_block_all
-            RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> R.id.menu_redirect_ask_same_domain_block_cross
-        }
-        menu.findItem(menuItemId)?.isChecked = true
-    }
-
-    private fun handleRedirectPolicySelection(menuItem: MenuItem) {
-        val selectedPolicy = when (menuItem.itemId) {
-            R.id.menu_redirect_allow_all -> RedirectPolicy.ALLOW_ALL
-            R.id.menu_redirect_ask_always -> RedirectPolicy.ASK_ALWAYS
-            R.id.menu_redirect_ask_cross_origin -> RedirectPolicy.ASK_CROSS_ORIGIN
-            R.id.menu_redirect_block_cross_origin -> RedirectPolicy.BLOCK_CROSS_ORIGIN
-            R.id.menu_redirect_block_all -> RedirectPolicy.BLOCK_ALL
-            R.id.menu_redirect_ask_same_domain_block_cross -> RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS
-            else -> RedirectPolicy.ALLOW_ALL
-        }
-
-        updateRedirectPolicy(selectedPolicy)
-        toastOnUi("重定向策略已更新")
-    }
-
-    fun updateRedirectPolicy(policy: RedirectPolicy) {
-        viewModel.rssSource?.let { source ->
-            viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
-            redirectPolicy = policy
-        }
-    }
-
-    private fun upFragments() {
-        lifecycleScope.launch {
-            val sorts = viewModel.rssSource?.sortUrls().orEmpty()
-
-            sortList.clear()
-            sortList.addAll(sorts)
-
-            if (sortList.size <= 1) {
-                binding.llTab.gone()
-            } else {
-                binding.llTab.visible()
-            }
-
-            adapter = TabFragmentPageAdapter(this@RssSortActivity, sortList)
-            binding.viewPager.adapter = adapter
-
-            // 重新绑定 TabLayout
-            TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
-                tab.text = sortList[position].first
-            }.attach()
-        }
-
-        binding.btnExpandTabs.setOnClickListener {
-            val popup = PopupMenu(this, it)
-            sortList.forEachIndexed { index, pair ->
-                popup.menu.add(0, index, index, pair.first)
-            }
-            popup.setOnMenuItemClickListener { item ->
-                binding.viewPager.setCurrentItem(item.itemId, true)
-                true
-            }
-            popup.setOnDismissListener {
-                binding.btnExpandTabs.isChecked = false
-            }
-            popup.show()
-        }
-
-    }
-
-    private fun setSourceVariable() {
-        lifecycleScope.launch {
-            val source = viewModel.rssSource
-            if (source == null) {
-                toastOnUi("源不存在")
-                return@launch
-            }
-            val comment =
-                source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-            val variable = withContext(Dispatchers.IO) { source.getVariable() }
-            showDialogFragment(
-                VariableDialog(
-                    getString(R.string.set_source_variable),
-                    source.getKey(),
-                    variable,
-                    comment
-                )
-            )
-        }
-    }
-
-    override fun setVariable(key: String, variable: String?) {
-        viewModel.rssSource?.setVariable(variable)
-    }
-
-    private class TabFragmentPageAdapter(
-        activity: FragmentActivity,
-        private val items: List<Pair<String, String>>
-    ) : FragmentStateAdapter(activity) {
-
-        override fun getItemCount(): Int = items.size
-
-        override fun createFragment(position: Int): Fragment {
-            val sort = items[position]
-            return RssArticlesFragment(sort.first, sort.second)
-        }
-    }
-
-    companion object {
-        fun start(context: Context, sortUrl: String?, sourceUrl: String, key: String? = null) {
-            context.startActivity<RssSortActivity> {
-                putExtra("sortUrl", sortUrl)
-                putExtra("sourceUrl", sourceUrl)
-                putExtra("key", key)
-            }
-        }
-    }
-}

+ 189 - 0
app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt

@@ -0,0 +1,189 @@
+@file:Suppress("DEPRECATION")
+
+package io.legado.app.ui.rss.article
+
+import android.app.Activity
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.appcompat.app.AppCompatActivity
+import io.legado.app.R
+import io.legado.app.data.entities.RssReadRecord
+import io.legado.app.ui.login.SourceLoginActivity
+import io.legado.app.ui.rss.navigation.RssMainNavContract
+import io.legado.app.ui.rss.read.RedirectPolicy
+import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
+import io.legado.app.ui.widget.dialog.VariableDialog
+import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.androidx.compose.koinViewModel
+import androidx.compose.ui.res.stringResource
+
+@Composable
+fun RssSortRouteScreen(
+    sourceUrl: String?,
+    initialSortUrl: String?,
+    onBackClick: () -> Unit,
+    viewModel: RssSortViewModel = koinViewModel()
+) {
+    val context = LocalContext.current
+    val activity = LocalActivity.current as? AppCompatActivity
+    val scope = rememberCoroutineScope()
+
+    var sortList by remember(sourceUrl) { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
+    var articleStyle by remember(sourceUrl) { mutableIntStateOf(0) }
+    var redirectPolicy by remember(sourceUrl) { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
+    var screenTitle by remember(sourceUrl) { mutableStateOf("") }
+
+    var showReadRecordSheet by remember { mutableStateOf(false) }
+    var readRecords by remember { mutableStateOf<List<RssReadRecord>>(emptyList()) }
+
+    fun reloadSourceState() {
+        viewModel.initData(sourceUrl) {
+            scope.launch {
+                sortList = viewModel.loadSorts()
+                articleStyle = viewModel.currentArticleStyle()
+                screenTitle = viewModel.rssSource?.sourceName.orEmpty()
+                redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
+            }
+        }
+    }
+
+    val editSourceResult = rememberLauncherForActivityResult(
+        StartActivityContract(RssSourceEditActivity::class.java)
+    ) {
+        if (it.resultCode == Activity.RESULT_OK) {
+            reloadSourceState()
+        }
+    }
+
+    androidx.compose.runtime.LaunchedEffect(sourceUrl) {
+        reloadSourceState()
+    }
+
+    RssSortScreen(
+        title = screenTitle.ifBlank { stringResource(R.string.rss) },
+        sortList = sortList,
+        preferredSortUrl = initialSortUrl,
+        hasLogin = !viewModel.rssSource?.loginUrl.isNullOrBlank(),
+        redirectPolicy = redirectPolicy,
+        showReadRecordSheet = showReadRecordSheet,
+        readRecords = readRecords,
+        onBackClick = onBackClick,
+        onLogin = {
+            context.startActivity<SourceLoginActivity> {
+                putExtra("type", "rssSource")
+                putExtra("key", viewModel.rssSource?.sourceUrl)
+            }
+        },
+        onRefreshSort = {
+            viewModel.clearSortCache {
+                scope.launch { sortList = viewModel.loadSorts() }
+            }
+        },
+        onSetSourceVariable = {
+            scope.launch {
+                val source = viewModel.rssSource
+                if (source == null) {
+                    context.toastOnUi("源不存在")
+                    return@launch
+                }
+                val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
+                val variable = withContext(Dispatchers.IO) { source.getVariable() }
+                activity?.showDialogFragment(
+                    VariableDialog(
+                        context.getString(R.string.set_source_variable),
+                        source.getKey(),
+                        variable,
+                        comment
+                    )
+                )
+            }
+        },
+        onEditSource = {
+            viewModel.rssSource?.sourceUrl?.let { srcUrl ->
+                editSourceResult.launch { putExtra("sourceUrl", srcUrl) }
+            }
+        },
+        onSwitchLayout = {
+            viewModel.switchLayout()
+            articleStyle = viewModel.currentArticleStyle()
+        },
+        onReadRecord = {
+            scope.launch(Dispatchers.IO) {
+                val records = viewModel.getRecords()
+                withContext(Dispatchers.Main) {
+                    readRecords = records
+                    showReadRecordSheet = true
+                }
+            }
+        },
+        onDismissReadRecord = { showReadRecordSheet = false },
+        onClearReadRecord = {
+            viewModel.deleteAllRecord()
+            readRecords = emptyList()
+        },
+        onOpenReadRecord = { record ->
+            showReadRecordSheet = false
+            val openOrigin = record.origin.ifBlank {
+                viewModel.rssSource?.sourceUrl ?: sourceUrl.orEmpty()
+            }
+            if (openOrigin.isBlank()) {
+                context.toastOnUi(context.getString(R.string.error))
+            } else {
+                context.startActivity(
+                    RssMainNavContract.createRssReadIntent(
+                        context = context,
+                        title = record.title,
+                        origin = openOrigin,
+                        openUrl = record.record
+                    )
+                )
+            }
+        },
+        onClearArticles = { viewModel.clearArticles() },
+        onRedirectPolicyChanged = { policy ->
+            viewModel.rssSource?.let { source ->
+                viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
+                redirectPolicy = policy
+            }
+            context.toastOnUi("重定向策略已更新")
+        },
+        pagerContent = { _, sort ->
+            val pageViewModel: RssArticlesViewModel = koinViewModel(
+                key = "rss_${viewModel.url}_${sort.first}_${sort.second}"
+            )
+            RssArticlesPage(
+                sortName = sort.first,
+                sortUrl = sort.second,
+                articleStyle = articleStyle,
+                rssUrl = viewModel.url,
+                rssSource = viewModel.rssSource,
+                viewModel = pageViewModel,
+                onRead = { article ->
+                    viewModel.read(article)
+                    context.startActivity(
+                        RssMainNavContract.createRssReadIntent(
+                            context = context,
+                            title = article.title,
+                            origin = article.origin,
+                            link = article.link
+                        )
+                    )
+                }
+            )
+        }
+    )
+}

+ 364 - 0
app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt

@@ -0,0 +1,364 @@
+package io.legado.app.ui.rss.article
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
+import androidx.compose.material.icons.automirrored.filled.Login
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CleaningServices
+import androidx.compose.material.icons.filled.Dataset
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Style
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.RssReadRecord
+import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
+import io.legado.app.ui.rss.read.RedirectPolicy
+import io.legado.app.ui.rss.read.title
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
+import io.legado.app.ui.widget.components.button.TopBarActionButton
+import io.legado.app.ui.widget.components.button.TopBarNavigationButton
+import io.legado.app.ui.widget.components.card.GlassCard
+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.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RssSortScreen(
+    title: String,
+    sortList: List<Pair<String, String>>,
+    preferredSortUrl: String?,
+    hasLogin: Boolean,
+    redirectPolicy: RedirectPolicy,
+    showReadRecordSheet: Boolean,
+    readRecords: List<RssReadRecord>,
+    onBackClick: () -> Unit,
+    onLogin: () -> Unit,
+    onRefreshSort: () -> Unit,
+    onSetSourceVariable: () -> Unit,
+    onEditSource: () -> Unit,
+    onSwitchLayout: () -> Unit,
+    onReadRecord: () -> Unit,
+    onDismissReadRecord: () -> Unit,
+    onClearReadRecord: () -> Unit,
+    onOpenReadRecord: (RssReadRecord) -> Unit,
+    onClearArticles: () -> Unit,
+    onRedirectPolicyChanged: (RedirectPolicy) -> Unit,
+    pagerContent: @Composable (index: Int, item: Pair<String, String>) -> Unit
+) {
+    val scope = rememberCoroutineScope()
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    val initialPage = remember(sortList, preferredSortUrl) {
+        val index = sortList.indexOfFirst { it.second == preferredSortUrl }
+        if (index >= 0) index else 0
+    }
+    val pagerState = rememberPagerState(initialPage = initialPage) {
+        sortList.size.coerceAtLeast(1)
+    }
+    val selectedTabIndex = pagerState.currentPage.coerceIn(0, (sortList.size - 1).coerceAtLeast(0))
+    val tabTitles = sortList.map { it.first }
+
+    var showMainMenu by remember { mutableStateOf(false) }
+    var showRedirectMenu by remember { mutableStateOf(false) }
+    var showGroupMenu by remember { mutableStateOf(false) }
+
+    BackHandler(enabled = showMainMenu || showRedirectMenu || showGroupMenu) {
+        showMainMenu = false
+        showRedirectMenu = false
+        showGroupMenu = false
+    }
+
+    LaunchedEffect(sortList.size) {
+        if (sortList.isEmpty()) return@LaunchedEffect
+        val safeIndex = pagerState.currentPage.coerceIn(0, sortList.lastIndex)
+        if (safeIndex != pagerState.currentPage) {
+            pagerState.scrollToPage(safeIndex)
+        }
+    }
+
+    Column(modifier = Modifier.fillMaxSize()) {
+        GlassMediumFlexibleTopAppBar(
+            title = title,
+            scrollBehavior = scrollBehavior,
+            navigationIcon = {
+                TopBarNavigationButton(onClick = onBackClick, imageVector = Icons.AutoMirrored.Filled.ArrowBack)
+            },
+            actions = {
+                TopBarActionButton(
+                    onClick = { showMainMenu = true },
+                    imageVector = Icons.Default.MoreVert,
+                    contentDescription = "Menu"
+                )
+                RoundDropdownMenu(
+                    expanded = showMainMenu,
+                    onDismissRequest = { showMainMenu = false }
+                ) { dismiss ->
+                    if (hasLogin) {
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.login),
+                            leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
+                            onClick = {
+                                dismiss()
+                                onLogin()
+                            }
+                        )
+                    }
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.refresh_sort),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
+                        onClick = {
+                            dismiss()
+                            onRefreshSort()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.set_source_variable),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
+                        onClick = {
+                            dismiss()
+                            onSetSourceVariable()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.edit_source),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
+                        onClick = {
+                            dismiss()
+                            onEditSource()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.switchLayout),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Style) },
+                        onClick = {
+                            dismiss()
+                            onSwitchLayout()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.read_record),
+                        leadingIcon = { MenuItemIcon(Icons.Default.History) },
+                        onClick = {
+                            dismiss()
+                            onReadRecord()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.clear),
+                        leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
+                        onClick = {
+                            dismiss()
+                            onClearArticles()
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.redirect_policy),
+                        onClick = { showRedirectMenu = true }
+                    )
+                }
+
+                RoundDropdownMenu(
+                    expanded = showRedirectMenu,
+                    onDismissRequest = { showRedirectMenu = false },
+                    modifier = Modifier.width(280.dp)
+                ) { dismiss ->
+                    RedirectPolicy.entries.forEach { policy ->
+                        RoundDropdownMenuItem(
+                            text = policy.title(),
+                            isSelected = policy == redirectPolicy,
+                            onClick = {
+                                dismiss()
+                                showMainMenu = false
+                                onRedirectPolicyChanged(policy)
+                            }
+                        )
+                    }
+                }
+            },
+            bottomContent = {
+                if (sortList.size > 1) {
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .adaptiveHorizontalPaddingTab(),
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        AppTabRow(
+                            tabTitles = tabTitles,
+                            selectedTabIndex = selectedTabIndex,
+                            onTabSelected = { index ->
+                                scope.launch { pagerState.animateScrollToPage(index) }
+                            },
+                            modifier = Modifier.weight(1f)
+                        )
+
+                        if (BookshelfConfig.shouldShowExpandButton) {
+                            Box {
+                                SmallOutlinedIconToggleButton(
+                                    checked = showGroupMenu,
+                                    onCheckedChange = { showGroupMenu = it },
+                                    imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
+                                    contentDescription = stringResource(R.string.group_manage)
+                                )
+                                RoundDropdownMenu(
+                                    expanded = showGroupMenu,
+                                    onDismissRequest = { showGroupMenu = false }
+                                ) { dismiss ->
+                                    sortList.forEachIndexed { index, group ->
+                                        RoundDropdownMenuItem(
+                                            text = group.first,
+                                            onClick = {
+                                                scope.launch { pagerState.animateScrollToPage(index) }
+                                                dismiss()
+                                            },
+                                            trailingIcon = {
+                                                if (selectedTabIndex == index) {
+                                                    Icon(
+                                                        Icons.Default.Check,
+                                                        null,
+                                                        modifier = Modifier.size(18.dp)
+                                                    )
+                                                }
+                                            }
+                                        )
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        )
+
+        if (sortList.isEmpty()) {
+            EmptyMessage(
+                message = stringResource(R.string.empty),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(24.dp)
+            )
+        } else {
+            HorizontalPager(
+                state = pagerState,
+                contentPadding = PaddingValues(bottom = 0.dp),
+                modifier = Modifier.fillMaxSize()
+            ) { page ->
+                val item = sortList.getOrNull(page) ?: return@HorizontalPager
+                pagerContent(page, item)
+            }
+        }
+    }
+
+    RssReadRecordSheet(
+        show = showReadRecordSheet,
+        records = readRecords,
+        onDismissRequest = onDismissReadRecord,
+        onClear = onClearReadRecord,
+        onOpen = onOpenReadRecord
+    )
+}
+
+@Composable
+private fun RssReadRecordSheet(
+    show: Boolean,
+    records: List<RssReadRecord>,
+    onDismissRequest: () -> Unit,
+    onClear: () -> Unit,
+    onOpen: (RssReadRecord) -> Unit
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.read_record),
+        endAction = {
+            SmallIconButton(
+                onClick = onClear,
+                imageVector = Icons.Default.CleaningServices,
+                contentDescription = stringResource(R.string.clear)
+            )
+        }
+    ) {
+        if (records.isEmpty()) {
+            EmptyMessage(
+                message = stringResource(R.string.empty),
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 24.dp)
+            )
+        } else {
+            LazyColumn(modifier = Modifier.fillMaxWidth()) {
+                items(records, key = { it.record }) { record ->
+                    GlassCard(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(bottom = 8.dp),
+                        onClick = { onOpen(record) },
+                        containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+                    ) {
+                        Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
+                            AppText(
+                                text = record.title.orEmpty(),
+                                style = LegadoTheme.typography.titleSmall,
+                                color = LegadoTheme.colorScheme.onSurface,
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis
+                            )
+                            AppText(
+                                text = record.record,
+                                style = LegadoTheme.typography.bodySmall,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                maxLines = 1,
+                                overflow = TextOverflow.Ellipsis,
+                                modifier = Modifier.padding(top = 4.dp)
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+

+ 17 - 10
app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt

@@ -9,10 +9,10 @@ import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssReadRecord
 import io.legado.app.data.entities.RssSource
 import io.legado.app.help.source.removeSortCache
+import io.legado.app.help.source.sortUrls
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 
-
 class RssSortViewModel(application: Application) : BaseViewModel(application) {
     var url: String? = null
     var rssSource: RssSource? = null
@@ -22,14 +22,18 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
     val isWaterLayout get() = rssSource?.articleStyle == 3
 
     fun initData(intent: Intent, finally: () -> Unit) {
+        initData(intent.getStringExtra("url") ?: intent.getStringExtra("sourceUrl"), finally)
+    }
+
+    fun initData(sourceUrl: String?, finally: () -> Unit) {
         execute {
-            url = intent.getStringExtra("url")
-            url?.let { url ->
-                rssSource = appDb.rssSourceDao.getByKey(url)
+            url = sourceUrl
+            url?.let { sourceUrl ->
+                rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
                 rssSource?.let {
                     titleLiveData.postValue(it.sourceName)
                 } ?: let {
-                    rssSource = RssSource(sourceUrl = url)
+                    rssSource = RssSource(sourceUrl = sourceUrl)
                 }
             }
         }.onFinally {
@@ -37,6 +41,12 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
         }
     }
 
+    suspend fun loadSorts(): List<Pair<String, String>> {
+        return rssSource?.sortUrls().orEmpty()
+    }
+
+    fun currentArticleStyle(): Int = rssSource?.articleStyle ?: 0
+
     fun switchLayout() {
         rssSource?.let {
             if (it.articleStyle < 3) {
@@ -67,8 +77,6 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
                 appDb.rssArticleDao.delete(it)
             }
             order = System.currentTimeMillis()
-        }.onSuccess {
-
         }
     }
 
@@ -84,7 +92,7 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
         return appDb.rssReadRecordDao.getRecords()
     }
 
-    fun countRecords() : Int {
+    fun countRecords(): Int {
         return appDb.rssReadRecordDao.countRecords
     }
 
@@ -102,5 +110,4 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
             appCtx.toastOnUi("保存失败: ${it.localizedMessage}")
         }
     }
-
-}
+}

+ 0 - 91
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt

@@ -1,91 +0,0 @@
-package io.legado.app.ui.rss.favorites
-
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.ViewGroup
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.RequestOptions
-import com.bumptech.glide.request.target.Target
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.RssStar
-import io.legado.app.databinding.ItemRssArticleBinding
-import io.legado.app.help.glide.ImageLoader
-import io.legado.app.help.glide.OkHttpModelLoader
-import io.legado.app.utils.gone
-import io.legado.app.utils.visible
-
-
-class RssFavoritesAdapter(context: Context, val callBack: CallBack) :
-    RecyclerAdapter<RssStar, ItemRssArticleBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding {
-        return ItemRssArticleBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemRssArticleBinding,
-        item: RssStar,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            tvTitle.text = item.title
-            tvPubDate.text = item.pubDate
-            if (item.image.isNullOrBlank()) {
-                imageView.gone()
-            } else {
-                val options =
-                    RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
-                ImageLoader.load(context, item.image)
-                    .apply(options)
-                    .addListener(object : RequestListener<Drawable> {
-                        override fun onLoadFailed(
-                            e: GlideException?,
-                            model: Any?,
-                            target: Target<Drawable>,
-                            isFirstResource: Boolean
-                        ): Boolean {
-                            imageView.gone()
-                            return false
-                        }
-
-                        override fun onResourceReady(
-                            resource: Drawable,
-                            model: Any,
-                            target: Target<Drawable>?,
-                            dataSource: DataSource,
-                            isFirstResource: Boolean
-                        ): Boolean {
-                            imageView.visible()
-                            return false
-                        }
-
-                    })
-                    .into(imageView)
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
-        holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.readRss(it)
-            }
-        }
-        holder.itemView.setOnLongClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.delStar(it)
-            }
-            true
-        }
-    }
-
-    interface CallBack {
-        fun readRss(rssStar: RssStar)
-
-        fun delStar(rssStar: RssStar)
-    }
-}

+ 0 - 67
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt

@@ -1,67 +0,0 @@
-package io.legado.app.ui.rss.favorites
-
-//import io.legado.app.lib.theme.primaryColor
-import android.os.Bundle
-import android.view.View
-import io.legado.app.R
-import io.legado.app.base.BaseBottomSheetDialogFragment
-import io.legado.app.data.entities.RssArticle
-import io.legado.app.databinding.DialogRssFavoriteConfigBinding
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-
-class RssFavoritesDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_rss_favorite_config) {
-
-    constructor(rssArticle: RssArticle) : this() {
-        arguments = Bundle().apply {
-            putString("title", rssArticle.title)
-            putString("group", rssArticle.group)
-        }
-    }
-
-    private val binding by viewBinding(DialogRssFavoriteConfigBinding::bind)
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        //binding.toolBar.setBackgroundColor(primaryColor)
-        val arguments = arguments ?: let {
-            dismiss()
-            return
-        }
-
-        var title = arguments.getString("title")
-        var group = arguments.getString("group")
-        binding.run {
-            editTitle.setText(title)
-            editGroup.setText(group)
-            tvCancel.setOnClickListener {
-                dismiss()
-            }
-            tvOk.setOnClickListener {
-                val editTitle = editTitle.text.toString()
-                if (editTitle.isNotBlank()) {
-                    title = editTitle
-                }
-                val editGroup = editGroup.text.toString()
-                if (editGroup.isNotBlank()) {
-                    group = editGroup
-                }
-                callback?.updateFavorite(title, group)
-                dismiss()
-            }
-            tvFooterLeft.setOnClickListener {
-                callback?.deleteFavorite()
-                dismiss()
-            }
-        }
-    }
-
-    val callback get() = (parentFragment as? Callback) ?: (activity as? Callback)
-
-    interface Callback {
-
-        fun updateFavorite(title: String?, group: String?)
-
-        fun deleteFavorite()
-
-    }
-
-}

+ 9 - 6
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt

@@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.viewmodel.compose.viewModel
 import io.legado.app.R
 import io.legado.app.data.entities.RssStar
-import io.legado.app.ui.rss.read.ReadRssActivity
+import io.legado.app.ui.rss.navigation.RssMainNavContract
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SourceIcon
@@ -224,11 +224,14 @@ fun RssFavoritesScreen(
                         } else null,
                         trailingAction = {
                             val openAction = {
-                                context.startActivity<ReadRssActivity> {
-                                    putExtra("title", rssStar.title)
-                                    putExtra("origin", rssStar.origin)
-                                    putExtra("link", rssStar.link)
-                                }
+                                context.startActivity(
+                                    RssMainNavContract.createRssReadIntent(
+                                        context = context,
+                                        title = rssStar.title,
+                                        origin = rssStar.origin,
+                                        link = rssStar.link
+                                    )
+                                )
                             }
                             SmallIconButton(
                                 onClick = openAction,

+ 86 - 0
app/src/main/java/io/legado/app/ui/rss/navigation/RssMainNavContract.kt

@@ -0,0 +1,86 @@
+package io.legado.app.ui.rss.navigation
+
+import android.content.Context
+import android.content.Intent
+import androidx.navigation3.runtime.NavKey
+import io.legado.app.ui.main.MainActivity
+import io.legado.app.ui.rss.article.MainRouteRssSort
+import io.legado.app.ui.rss.read.MainRouteRssRead
+
+object RssMainNavContract {
+
+    private const val ROUTE_RSS_SORT = "rss/sort"
+    private const val ROUTE_RSS_READ = "rss/read"
+
+    private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
+    private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
+    private const val EXTRA_RSS_KEY = "extra_rss_key"
+
+    private const val EXTRA_RSS_READ_TITLE = "extra_rss_read_title"
+    private const val EXTRA_RSS_READ_ORIGIN = "extra_rss_read_origin"
+    private const val EXTRA_RSS_READ_LINK = "extra_rss_read_link"
+    private const val EXTRA_RSS_READ_OPEN_URL = "extra_rss_read_open_url"
+
+    fun createRssSortIntent(
+        context: Context,
+        sourceUrl: String,
+        sortUrl: String? = null,
+        key: String? = null
+    ): Intent {
+        return Intent(context, MainActivity::class.java).apply {
+            putExtra(MainActivity.EXTRA_START_ROUTE, ROUTE_RSS_SORT)
+            putExtra(EXTRA_RSS_SOURCE_URL, sourceUrl)
+            putExtra(EXTRA_RSS_SORT_URL, sortUrl)
+            putExtra(EXTRA_RSS_KEY, key)
+        }
+    }
+
+    fun createRssReadIntent(
+        context: Context,
+        title: String? = null,
+        origin: String,
+        link: String? = null,
+        openUrl: String? = null
+    ): Intent {
+        return Intent(context, MainActivity::class.java).apply {
+            putExtra(MainActivity.EXTRA_START_ROUTE, ROUTE_RSS_READ)
+            putExtra(EXTRA_RSS_READ_TITLE, title)
+            putExtra(EXTRA_RSS_READ_ORIGIN, origin)
+            putExtra(EXTRA_RSS_READ_LINK, link)
+            putExtra(EXTRA_RSS_READ_OPEN_URL, openUrl)
+        }
+    }
+
+    fun resolveStartRoute(route: String?, intent: Intent?): NavKey? {
+        return when (route) {
+            ROUTE_RSS_SORT -> {
+                val sourceUrl = intent?.getStringExtra(EXTRA_RSS_SOURCE_URL)
+                if (sourceUrl.isNullOrBlank()) {
+                    null
+                } else {
+                    MainRouteRssSort(
+                        sourceUrl = sourceUrl,
+                        sortUrl = intent.getStringExtra(EXTRA_RSS_SORT_URL),
+                        key = intent.getStringExtra(EXTRA_RSS_KEY)
+                    )
+                }
+            }
+
+            ROUTE_RSS_READ -> {
+                val origin = intent?.getStringExtra(EXTRA_RSS_READ_ORIGIN)
+                if (origin.isNullOrBlank()) {
+                    null
+                } else {
+                    MainRouteRssRead(
+                        title = intent.getStringExtra(EXTRA_RSS_READ_TITLE),
+                        origin = origin,
+                        link = intent.getStringExtra(EXTRA_RSS_READ_LINK),
+                        openUrl = intent.getStringExtra(EXTRA_RSS_READ_OPEN_URL)
+                    )
+                }
+            }
+
+            else -> null
+        }
+    }
+}

+ 12 - 0
app/src/main/java/io/legado/app/ui/rss/read/MainRouteRssRead.kt

@@ -0,0 +1,12 @@
+package io.legado.app.ui.rss.read
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MainRouteRssRead(
+    val title: String? = null,
+    val origin: String,
+    val link: String? = null,
+    val openUrl: String? = null
+) : NavKey

+ 0 - 627
app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt

@@ -1,627 +0,0 @@
-package io.legado.app.ui.rss.read
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.pm.ActivityInfo
-import android.content.res.Configuration
-import android.net.Uri
-import android.net.http.SslError
-import android.os.Bundle
-import android.os.SystemClock
-import android.view.Gravity
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.WindowManager
-import android.webkit.JavascriptInterface
-import android.webkit.SslErrorHandler
-import android.webkit.URLUtil
-import android.webkit.WebChromeClient
-import android.webkit.WebResourceRequest
-import android.webkit.WebSettings
-import android.webkit.WebView
-import android.webkit.WebViewClient
-import androidx.activity.addCallback
-import androidx.activity.viewModels
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.widget.PopupMenu
-import androidx.core.net.toUri
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.size
-import androidx.lifecycle.lifecycleScope
-import com.script.rhino.runScriptWithContext
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppConst
-import io.legado.app.constant.AppLog
-import io.legado.app.data.entities.RssSource
-import io.legado.app.databinding.ActivityRssReadBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.http.CookieManager
-import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.lib.dialogs.selector
-import io.legado.app.model.Download
-import io.legado.app.ui.association.OnLineImportActivity
-import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.rss.favorites.RssFavoritesDialog
-import io.legado.app.utils.NetworkUtils
-import io.legado.app.utils.gone
-import io.legado.app.utils.invisible
-import io.legado.app.utils.isTrue
-import io.legado.app.utils.keepScreenOn
-import io.legado.app.utils.longSnackbar
-import io.legado.app.utils.openUrl
-import io.legado.app.utils.setDarkeningAllowed
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
-import io.legado.app.utils.share
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.textArray
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.toggleSystemBar
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import org.apache.commons.text.StringEscapeUtils
-import org.jsoup.Jsoup
-import splitties.views.bottomPadding
-import java.net.URLDecoder
-
-/**
- * rss阅读界面
- */
-class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>(),
-    RssFavoritesDialog.Callback {
-
-    enum class RedirectPolicy {
-        ALLOW_ALL,
-        ASK_ALWAYS,
-        ASK_CROSS_ORIGIN,
-        BLOCK_CROSS_ORIGIN,
-        BLOCK_ALL,
-        ASK_SAME_DOMAIN_BLOCK_CROSS;
-
-        companion object {
-            fun fromString(value: String?): RedirectPolicy {
-                return entries.find { it.name.equals(value, ignoreCase = true) } ?: ALLOW_ALL
-            }
-        }
-
-    }
-
-    var redirectPolicy: RedirectPolicy = RedirectPolicy.ALLOW_ALL
-
-    override val binding by viewBinding(ActivityRssReadBinding::inflate)
-    override val viewModel by viewModels<ReadRssViewModel>()
-
-    private var starMenuItem: MenuItem? = null
-    private var ttsMenuItem: MenuItem? = null
-    private var redirectPolicyMenu: MenuItem? = null
-    private var isFullScreen = false
-    private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
-    private val rssJsExtensions by lazy { RssJsExtensions(this, viewModel.rssSource) }
-
-    fun getSource(): RssSource? {
-        return viewModel.rssSource
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        viewModel.upStarMenuData.observe(this) { upStarMenu() }
-        viewModel.upTtsMenuData.observe(this) { upTtsMenu(it) }
-        binding.titleBar.title = intent.getStringExtra("title")
-        initView()
-        initWebView()
-        initLiveData()
-        viewModel.initData(intent)
-        onBackPressedDispatcher.addCallback(this) {
-            if (isFullScreen) {
-                toggleFullScreen()
-            } else {
-                if (binding.customWebView.size > 0) {
-                    customWebViewCallback?.onCustomViewHidden()
-                    return@addCallback
-                } else if (binding.webView.canGoBack()
-                    && binding.webView.copyBackForwardList().size > 1
-                ) {
-                    binding.webView.goBack()
-                    return@addCallback
-                }
-                finish()
-            }
-        }
-    }
-
-    @Suppress("DEPRECATION")
-    @SuppressLint("SwitchIntDef")
-    override fun onConfigurationChanged(newConfig: Configuration) {
-        super.onConfigurationChanged(newConfig)
-        when (newConfig.orientation) {
-            Configuration.ORIENTATION_LANDSCAPE -> {
-                window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
-                window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
-            }
-
-            Configuration.ORIENTATION_PORTRAIT -> {
-                window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
-                window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
-            }
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_read, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        starMenuItem = menu.findItem(R.id.menu_rss_star)
-        ttsMenuItem = menu.findItem(R.id.menu_aloud)
-        redirectPolicyMenu = menu.findItem(R.id.menu_redirect_policy)
-        redirectPolicyMenu?.setOnMenuItemClickListener {
-            showRedirectPolicySubMenu()
-            true
-        }
-        upStarMenu()
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank()
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_full_screen -> toggleFullScreen()
-            R.id.menu_rss_refresh -> viewModel.refresh {
-                binding.webView.reload()
-            }
-
-            R.id.menu_rss_star -> {
-                viewModel.addFavorite()
-                viewModel.rssArticle?.let {
-                    showDialogFragment(RssFavoritesDialog(it))
-                }
-            }
-            R.id.menu_share_it -> {
-                binding.webView.url?.let {
-                    share(it)
-                } ?: viewModel.rssArticle?.let {
-                    share(it.link)
-                } ?: toastOnUi(R.string.null_url)
-            }
-
-            R.id.menu_aloud -> readAloud()
-            R.id.menu_login -> startActivity<SourceLoginActivity> {
-                putExtra("type", "rssSource")
-                putExtra("key", viewModel.rssSource?.sourceUrl)
-            }
-
-            R.id.menu_browser_open -> binding.webView.url?.let {
-                openUrl(it)
-            } ?: toastOnUi("url null")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun updateFavorite(title: String?, group: String?) {
-        viewModel.rssArticle?.let {
-            if (title != null) {
-                it.title = title
-            }
-            if (group != null) {
-                it.group = group
-            }
-        }
-        viewModel.updateFavorite()
-    }
-
-    override fun deleteFavorite() {
-        viewModel.delFavorite()
-    }
-
-    @JavascriptInterface
-    fun isNightTheme(): Boolean {
-        return AppConfig.isNightTheme
-    }
-
-    private fun showRedirectPolicySubMenu() {
-        val popup = PopupMenu(this, binding.titleBar, Gravity.END)
-        popup.menuInflater.inflate(R.menu.menu_redirect_policy_submenu, popup.menu)
-        setCurrentRedirectPolicyChecked(popup.menu)
-        popup.setOnMenuItemClickListener { menuItem ->
-            handleRedirectPolicySelection(menuItem)
-            true
-        }
-        popup.show()
-    }
-
-    private fun setCurrentRedirectPolicyChecked(menu: Menu) {
-        val currentPolicy = redirectPolicy
-        val menuItemId = when (currentPolicy) {
-            RedirectPolicy.ALLOW_ALL -> R.id.menu_redirect_allow_all
-            RedirectPolicy.ASK_ALWAYS -> R.id.menu_redirect_ask_always
-            RedirectPolicy.ASK_CROSS_ORIGIN -> R.id.menu_redirect_ask_cross_origin
-            RedirectPolicy.BLOCK_CROSS_ORIGIN -> R.id.menu_redirect_block_cross_origin
-            RedirectPolicy.BLOCK_ALL -> R.id.menu_redirect_block_all
-            RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> R.id.menu_redirect_ask_same_domain_block_cross
-        }
-        menu.findItem(menuItemId)?.isChecked = true
-    }
-
-    private fun handleRedirectPolicySelection(menuItem: MenuItem) {
-        val selectedPolicy = when (menuItem.itemId) {
-            R.id.menu_redirect_allow_all -> RedirectPolicy.ALLOW_ALL
-            R.id.menu_redirect_ask_always -> RedirectPolicy.ASK_ALWAYS
-            R.id.menu_redirect_ask_cross_origin -> RedirectPolicy.ASK_CROSS_ORIGIN
-            R.id.menu_redirect_block_cross_origin -> RedirectPolicy.BLOCK_CROSS_ORIGIN
-            R.id.menu_redirect_block_all -> RedirectPolicy.BLOCK_ALL
-            R.id.menu_redirect_ask_same_domain_block_cross -> RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS
-            else -> RedirectPolicy.ALLOW_ALL
-        }
-
-        updateRedirectPolicy(selectedPolicy)
-        toastOnUi("重定向策略已更新")
-    }
-
-    private fun toggleFullScreen() {
-        isFullScreen = !isFullScreen
-
-        toggleSystemBar(!isFullScreen)
-
-        if (isFullScreen) {
-            supportActionBar?.hide()
-            toastOnUi("可通过系统手势退出全屏。")
-        } else {
-            supportActionBar?.show()
-        }
-    }
-
-    private fun initView() {
-        binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
-            val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
-            val insets = windowInsets.getInsets(typeMask)
-            view.bottomPadding = insets.bottom
-            windowInsets
-        }
-    }
-
-    @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
-    private fun initWebView() {
-        //binding.progressBar.fontColor = accentColor
-        binding.webView.webChromeClient = CustomWebChromeClient()
-        binding.webView.webViewClient = CustomWebViewClient()
-        binding.webView.settings.apply {
-            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
-            domStorageEnabled = true
-            allowContentAccess = true
-            builtInZoomControls = true
-            displayZoomControls = false
-            setDarkeningAllowed(AppConfig.isNightTheme)
-        }
-        binding.webView.addJavascriptInterface(this, "thisActivity")
-        binding.webView.setOnLongClickListener {
-            val hitTestResult = binding.webView.hitTestResult
-            if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
-                hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
-            ) {
-                hitTestResult.extra?.let { webPic ->
-                    selector(
-                        arrayListOf(
-                            SelectItem(getString(R.string.action_save), "save"),
-                        )
-                    ) { _, charSequence, _ ->
-                        when (charSequence.value) {
-                            "save" -> saveImage(webPic)
-                        }
-                    }
-                    return@setOnLongClickListener true
-                }
-            }
-            return@setOnLongClickListener false
-        }
-        binding.webView.setDownloadListener { url, _, contentDisposition, _, _ ->
-            var fileName = URLUtil.guessFileName(url, contentDisposition, null)
-            fileName = URLDecoder.decode(fileName, "UTF-8")
-            binding.llView.longSnackbar(fileName, getString(R.string.action_download)) {
-                Download.start(this, url, fileName)
-            }
-        }
-
-    }
-
-    private fun saveImage(webPic: String) {
-        viewModel.saveImage(webPic)
-    }
-
-    @SuppressLint("SetJavaScriptEnabled")
-    private fun initLiveData() {
-        viewModel.contentLiveData.observe(this) { content ->
-            viewModel.rssArticle?.let {
-                if (viewModel.rssSource != null) {
-                    redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
-                }
-                upJavaScriptEnable()
-                val url = NetworkUtils.getAbsoluteURL(it.origin, it.link)
-                val html = viewModel.clHtml(content)
-                binding.webView.settings.userAgentString =
-                    viewModel.headerMap[AppConst.UA_NAME] ?: AppConfig.userAgent
-                if (viewModel.rssSource?.loadWithBaseUrl == true) {
-                    binding.webView
-                        .loadDataWithBaseURL(url, html, "text/html", "utf-8", url)//不想用baseUrl进else
-                } else {
-                    binding.webView
-                        .loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url)
-                }
-            }
-        }
-        viewModel.urlLiveData.observe(this) {
-            upJavaScriptEnable()
-            CookieManager.applyToWebView(it.url)
-            binding.webView.settings.userAgentString = it.getUserAgent()
-            binding.webView.loadUrl(it.url, it.headerMap)
-        }
-    }
-
-    @SuppressLint("SetJavaScriptEnabled")
-    private fun upJavaScriptEnable() {
-        if (viewModel.rssSource?.enableJs == true) {
-            binding.webView.settings.javaScriptEnabled = true
-        }
-    }
-
-    fun updateRedirectPolicy(policy: RedirectPolicy) {
-        viewModel.rssSource?.let { source ->
-            viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
-            redirectPolicy = policy
-        }
-    }
-
-    private fun upStarMenu() {
-        starMenuItem?.isVisible = viewModel.rssArticle != null
-        if (viewModel.rssStar != null) {
-            starMenuItem?.setIcon(R.drawable.ic_star)
-            starMenuItem?.setTitle(R.string.in_favorites)
-        } else {
-            starMenuItem?.setIcon(R.drawable.ic_star_border)
-            starMenuItem?.setTitle(R.string.out_favorites)
-        }
-        //starMenuItem?.icon?.setTintMutate(primaryTextColor)
-    }
-
-    private fun upTtsMenu(isPlaying: Boolean) {
-        lifecycleScope.launch {
-            if (isPlaying) {
-                ttsMenuItem?.setIcon(R.drawable.ic_stop_black_24dp)
-                ttsMenuItem?.setTitle(R.string.aloud_stop)
-            } else {
-                ttsMenuItem?.setIcon(R.drawable.ic_volume_up)
-                ttsMenuItem?.setTitle(R.string.read_aloud)
-            }
-            //ttsMenuItem?.icon?.setTintMutate(primaryTextColor)
-        }
-    }
-
-    @SuppressLint("SetJavaScriptEnabled")
-    private fun readAloud() {
-        if (viewModel.tts?.isSpeaking == true) {
-            viewModel.tts?.stop()
-            upTtsMenu(false)
-        } else {
-            binding.webView.settings.javaScriptEnabled = true
-            binding.webView.evaluateJavascript("document.documentElement.outerHTML") {
-                val html = StringEscapeUtils.unescapeJson(it)
-                    .replace("^\"|\"$".toRegex(), "")
-                viewModel.readAloud(
-                    Jsoup.parse(html)
-                        .textArray()
-                        .joinToString("\n")
-                )
-            }
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        binding.webView.destroy()
-    }
-
-    inner class CustomWebChromeClient : WebChromeClient() {
-
-        override fun onProgressChanged(view: WebView?, newProgress: Int) {
-            super.onProgressChanged(view, newProgress)
-            binding.progressBar.setProgress(newProgress)
-            binding.progressBar.gone(newProgress == 100)
-        }
-
-        override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
-            requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
-            binding.llView.invisible()
-            binding.customWebView.addView(view)
-            customWebViewCallback = callback
-            keepScreenOn(true)
-            toggleSystemBar(false)
-        }
-
-        override fun onHideCustomView() {
-            binding.customWebView.removeAllViews()
-            binding.llView.visible()
-            requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
-            keepScreenOn(false)
-            toggleSystemBar(true)
-        }
-    }
-
-    inner class CustomWebViewClient : WebViewClient() {
-        private var lastUrl: String? = null
-        override fun shouldOverrideUrlLoading(
-            view: WebView,
-            request: WebResourceRequest
-        ): Boolean {
-            val targetUri = request.url
-            if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
-                return shouldOverrideUrlLoading(targetUri)
-            }
-            val currentUrl = lastUrl ?: view.url
-            val targetUrl = request.url.toString()
-            lastUrl = targetUrl
-            if (!request.isForMainFrame) return false
-            if (handleRedirect(view, currentUrl, targetUrl)) {
-                return true
-            }
-
-            return shouldOverrideUrlLoading(request.url)
-        }
-
-        @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress")
-        override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
-            val targetUri = url.toUri()
-            if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
-                return shouldOverrideUrlLoading(targetUri)
-            }
-            val currentUrl = lastUrl ?: view.url
-            val targetUrl = url
-            lastUrl = targetUrl
-            if (handleRedirect(view, currentUrl, targetUrl)) {
-                return true
-            }
-
-            return shouldOverrideUrlLoading(url.toUri())
-        }
-
-        private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
-            val fromHost = fromUrl?.toUri()?.host
-            val toHost = toUrl.toUri().host
-            val crossOrigin = fromHost != null && toHost != null && fromHost != toHost
-
-            return when (redirectPolicy) {
-                RedirectPolicy.ALLOW_ALL -> false
-                RedirectPolicy.BLOCK_ALL -> {
-                    toastOnUi("已阻止重定向")
-                    true
-                }
-                RedirectPolicy.ASK_ALWAYS -> {
-                    askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
-                    true
-                }
-                RedirectPolicy.ASK_CROSS_ORIGIN -> {
-                    if (crossOrigin) {
-                        askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
-                        true
-                    } else {
-                        false
-                    }
-                }
-                RedirectPolicy.BLOCK_CROSS_ORIGIN -> {
-                    if (crossOrigin) {
-                        toastOnUi("已阻止跨域重定向")
-                        true
-                    } else {
-                        false
-                    }
-                }
-                RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> {
-                    if (crossOrigin) {
-                        toastOnUi("已阻止域外跳转")
-                        true
-                    } else {
-                        askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
-                        true
-                    }
-                }
-            }
-        }
-
-        private fun askUser(fromUrl: String?, toUrl: String, onResult: (Boolean) -> Unit) {
-            AlertDialog.Builder(this@ReadRssActivity)
-                .setTitle("重定向请求")
-                .setMessage("是否允许页面跳转?\n\n来源:${fromUrl ?: "未知"}\n目标:$toUrl")
-                .setPositiveButton("允许") { _, _ -> onResult(true) }
-                .setNegativeButton("拒绝") { _, _ -> onResult(false) }
-                .setCancelable(true)
-                .show()
-        }
-
-        private fun shouldOverrideUrlLoading(url: Uri): Boolean {
-            val source = viewModel.rssSource
-            val js = source?.shouldOverrideUrlLoading
-            if (!js.isNullOrBlank()) {
-                val t = SystemClock.uptimeMillis()
-                val result = kotlin.runCatching {
-                    runScriptWithContext(lifecycleScope.coroutineContext) {
-                        source.evalJS(js) {
-                            put("java", rssJsExtensions)
-                            put("url", url.toString())
-                        }.toString()
-                    }
-                }.onFailure {
-                    AppLog.put("${source.getTag()}: url跳转拦截js出错", it)
-                }.getOrNull()
-                if (SystemClock.uptimeMillis() - t > 30) {
-                    AppLog.put("${source.getTag()}: url跳转拦截js执行耗时过长")
-                }
-                if (result.isTrue()) {
-                    return true
-                }
-            }
-            when (url.scheme) {
-                "http", "https", "jsbridge" -> {
-                    return false
-                }
-
-                "legado", "yuedu" -> {
-                    startActivity<OnLineImportActivity> {
-                        data = url
-                    }
-                    return true
-                }
-
-                else -> {
-                    binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
-                        openUrl(url)
-                    }
-                    return true
-                }
-            }
-        }
-
-        override fun onPageFinished(view: WebView, url: String?) {
-            super.onPageFinished(view, url)
-            view.title?.let { title ->
-                if (title != url && title != view.url && title.isNotBlank() && url != "about:blank") {
-                    binding.titleBar.title = title
-                } else {
-                    binding.titleBar.title = intent.getStringExtra("title")
-                }
-            }
-            viewModel.rssSource?.injectJs?.let {
-                if (it.isNotBlank()) {
-                    view.evaluateJavascript(it, null)
-                }
-            }
-        }
-
-        @SuppressLint("WebViewClientOnReceivedSslError")
-        override fun onReceivedSslError(
-            view: WebView?,
-            handler: SslErrorHandler?,
-            error: SslError?
-        ) {
-            handler?.proceed()
-        }
-    }
-
-    companion object {
-        fun start(context: Context, title: String?, url: String, origin: String) {
-            context.startActivity<ReadRssActivity> {
-                putExtra("title", title ?: "")
-                putExtra("origin", origin)
-                putExtra("openUrl", url)
-            }
-        }
-
-        private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() }
-    }
-
-}

+ 91 - 67
app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt

@@ -4,7 +4,6 @@ import android.app.Application
 import android.content.Intent
 import android.util.Base64
 import android.webkit.URLUtil
-import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.viewModelScope
 import com.script.rhino.runScriptWithContext
 import io.legado.app.base.BaseViewModel
@@ -21,59 +20,94 @@ import io.legado.app.model.rss.Rss
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import splitties.init.appCtx
 import kotlin.coroutines.coroutineContext
 
+data class ReadRssArgs(
+    val title: String? = null,
+    val origin: String,
+    val link: String? = null,
+    val openUrl: String? = null
+)
 
 class ReadRssViewModel(application: Application) : BaseViewModel(application) {
     var rssSource: RssSource? = null
     var rssArticle: RssArticle? = null
     var tts: TTS? = null
-    val contentLiveData = MutableLiveData<String>()
-    val urlLiveData = MutableLiveData<AnalyzeUrl>()
-    var rssStar: RssStar? = null
-    val upTtsMenuData = MutableLiveData<Boolean>()
-    val upStarMenuData = MutableLiveData<Boolean>()
     var headerMap: Map<String, String> = emptyMap()
 
+    private val _contentState = MutableStateFlow<String?>(null)
+    val contentState: StateFlow<String?> = _contentState.asStateFlow()
+
+    private val _urlState = MutableStateFlow<AnalyzeUrl?>(null)
+    val urlState: StateFlow<AnalyzeUrl?> = _urlState.asStateFlow()
+
+    private val _isSpeakingState = MutableStateFlow(false)
+    val isSpeakingState: StateFlow<Boolean> = _isSpeakingState.asStateFlow()
+
+    private val _rssStarState = MutableStateFlow<RssStar?>(null)
+    val rssStarState: StateFlow<RssStar?> = _rssStarState.asStateFlow()
+
     fun initData(intent: Intent) {
+        val origin = intent.getStringExtra("origin") ?: return
+        initData(
+            ReadRssArgs(
+                title = intent.getStringExtra("title"),
+                origin = origin,
+                link = intent.getStringExtra("link"),
+                openUrl = intent.getStringExtra("openUrl")
+            )
+        )
+    }
+
+    fun initData(args: ReadRssArgs) {
         execute {
-            val origin = intent.getStringExtra("origin") ?: return@execute
-            val link = intent.getStringExtra("link")
-            rssSource = appDb.rssSourceDao.getByKey(origin)
+            rssSource = appDb.rssSourceDao.getByKey(args.origin)
             headerMap = runScriptWithContext {
                 rssSource?.getHeaderMap() ?: emptyMap()
             }
-            if (link != null) {
-                rssStar = appDb.rssStarDao.get(origin, link)
-                rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.getByLink(origin, link)
-                val rssArticle = rssArticle ?: return@execute
-                if (!rssArticle.description.isNullOrBlank()) {
-                    contentLiveData.postValue(rssArticle.description!!)
+
+            val link = args.link
+            if (!link.isNullOrBlank()) {
+                _rssStarState.value = appDb.rssStarDao.get(args.origin, link)
+                rssArticle = _rssStarState.value?.toRssArticle() ?: appDb.rssArticleDao.getByLink(args.origin, link)
+                val article = rssArticle ?: return@execute
+                if (!article.description.isNullOrBlank()) {
+                    _contentState.value = article.description!!
                 } else {
                     rssSource?.let {
                         val ruleContent = it.ruleContent
                         if (!ruleContent.isNullOrBlank()) {
-                            loadContent(rssArticle, ruleContent)
+                            loadContent(article, ruleContent)
                         } else {
-                            loadUrl(rssArticle.link, rssArticle.origin)
+                            loadUrl(article.link, article.origin)
                         }
-                    } ?: loadUrl(rssArticle.link, rssArticle.origin)
+                    } ?: loadUrl(article.link, article.origin)
                 }
+                return@execute
+            }
+
+            val openUrl = args.openUrl
+            if (!openUrl.isNullOrBlank()) {
+                loadUrl(openUrl, args.origin)
+                return@execute
+            }
+
+            val ruleContent = rssSource?.ruleContent
+            if (ruleContent.isNullOrBlank()) {
+                loadUrl(args.origin, args.origin)
             } else {
-                val ruleContent = rssSource?.ruleContent
-                if (ruleContent.isNullOrBlank()) {
-                    loadUrl(origin, origin)
-                } else {
-                    val rssArticle = RssArticle()
-                    rssArticle.origin = origin
-                    rssArticle.link = origin
-                    rssArticle.title = rssSource!!.sourceName
-                    loadContent(rssArticle, ruleContent)
+                val article = RssArticle().apply {
+                    origin = args.origin
+                    this.link = args.origin
+                    title = rssSource!!.sourceName
                 }
+                rssArticle = article
+                loadContent(article, ruleContent)
             }
-        }.onFinally {
-            upStarMenuData.postValue(true)
         }
     }
 
@@ -85,7 +119,7 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
             coroutineContext = coroutineContext,
             hasLoginHeader = false
         )
-        urlLiveData.postValue(analyzeUrl)
+        _urlState.value = analyzeUrl
     }
 
     private fun loadContent(rssArticle: RssArticle, ruleContent: String) {
@@ -94,22 +128,22 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
             .onSuccess(IO) { body ->
                 rssArticle.description = body
                 appDb.rssArticleDao.insert(rssArticle)
-                rssStar?.let {
+                _rssStarState.value?.let {
                     it.description = body
                     appDb.rssStarDao.insert(it)
                 }
-                contentLiveData.postValue(body)
+                _contentState.value = body
             }.onError {
-                contentLiveData.postValue("加载正文失败\n${it.stackTraceToString()}")
+                _contentState.value = "加载正文失败\n${it.stackTraceToString()}"
             }
     }
 
     fun refresh(finish: () -> Unit) {
-        rssArticle?.let { rssArticle ->
+        rssArticle?.let { article ->
             rssSource?.let {
                 val ruleContent = it.ruleContent
                 if (!ruleContent.isNullOrBlank()) {
-                    loadContent(rssArticle, ruleContent)
+                    loadContent(article, ruleContent)
                 } else {
                     finish.invoke()
                 }
@@ -120,50 +154,38 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
         } ?: finish.invoke()
     }
 
-    fun favorite() {
+    fun addFavorite() {
         execute {
-            rssStar?.let {
-                appDb.rssStarDao.delete(it.origin, it.link)
-                rssStar = null
-            } ?: rssArticle?.toStar()?.let {
+            _rssStarState.value ?: rssArticle?.toStar()?.let {
                 appDb.rssStarDao.insert(it)
-                rssStar = it
+                _rssStarState.value = it
             }
-        }.onSuccess {
-            upStarMenuData.postValue(true)
         }
     }
 
-    fun addFavorite() {
-        execute {
-            rssStar ?: rssArticle?.toStar()?.let {
-                appDb.rssStarDao.insert(it)
-                rssStar = it
+    fun updateFavorite(title: String?, group: String?) {
+        rssArticle?.let { article ->
+            if (!title.isNullOrBlank()) {
+                article.title = title
+            }
+            group?.let {
+                article.group = it
             }
-        }.onSuccess {
-            upStarMenuData.postValue(true)
         }
-    }
-
-    fun updateFavorite() {
         execute {
             rssArticle?.toStar()?.let {
                 appDb.rssStarDao.update(it)
-                rssStar = it
+                _rssStarState.value = it
             }
-        }.onSuccess {
-            upStarMenuData.postValue(true)
         }
     }
 
     fun delFavorite() {
         execute {
-            rssStar?.let {
+            _rssStarState.value?.let {
                 appDb.rssStarDao.delete(it.origin, it.link)
-                rssStar = null
+                _rssStarState.value = null
             }
-        }.onSuccess {
-            upStarMenuData.postValue(true)
         }
     }
 
@@ -171,11 +193,10 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
         webPic ?: return
         execute {
             val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL")
-
             val success = ImageSaveUtils.saveImageToGallery(
                 context,
                 byteArray,
-                folderName = "Legado" // 可以自定义相册子目录
+                folderName = "Legado"
             )
             if (!success) throw NoStackTraceException("保存到相册失败")
         }.onError {
@@ -185,7 +206,6 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
         }
     }
 
-
     private suspend fun webData2bitmap(data: String): ByteArray? {
         return if (URLUtil.isValidUrl(data)) {
             okHttpClient.newCallResponseBody {
@@ -230,11 +250,11 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
             tts = TTS().apply {
                 setSpeakStateListener(object : TTS.SpeakStateListener {
                     override fun onStart() {
-                        upTtsMenuData.postValue(true)
+                        _isSpeakingState.value = true
                     }
 
                     override fun onDone() {
-                        upTtsMenuData.postValue(false)
+                        _isSpeakingState.value = false
                     }
                 })
             }
@@ -242,6 +262,11 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
         tts?.speak(text)
     }
 
+    fun stopReadAloud() {
+        tts?.stop()
+        _isSpeakingState.value = false
+    }
+
     fun updateRssSourceRedirectPolicy(sourceUrl: String, redirectPolicy: String) {
         execute {
             appDb.rssSourceDao.updateRedirectPolicy(sourceUrl, redirectPolicy)
@@ -255,5 +280,4 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
         super.onCleared()
         tts?.clearTts()
     }
-
-}
+}

+ 27 - 0
app/src/main/java/io/legado/app/ui/rss/read/RedirectPolicy.kt

@@ -0,0 +1,27 @@
+package io.legado.app.ui.rss.read
+
+enum class RedirectPolicy {
+    ALLOW_ALL,
+    ASK_ALWAYS,
+    ASK_CROSS_ORIGIN,
+    BLOCK_CROSS_ORIGIN,
+    BLOCK_ALL,
+    ASK_SAME_DOMAIN_BLOCK_CROSS;
+
+    companion object {
+        fun fromString(value: String?): RedirectPolicy {
+            return entries.find { it.name.equals(value, ignoreCase = true) } ?: ALLOW_ALL
+        }
+    }
+}
+
+fun RedirectPolicy.title(): String {
+    return when (this) {
+        RedirectPolicy.ALLOW_ALL -> "允许所有跳转"
+        RedirectPolicy.ASK_ALWAYS -> "总是询问"
+        RedirectPolicy.ASK_CROSS_ORIGIN -> "跨域询问"
+        RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> "同域询问,跨域拦截"
+        RedirectPolicy.BLOCK_CROSS_ORIGIN -> "拦截跨域"
+        RedirectPolicy.BLOCK_ALL -> "拦截所有"
+    }
+}

+ 16 - 3
app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt

@@ -13,7 +13,7 @@ import io.legado.app.ui.association.AddToBookshelfDialog
 import io.legado.app.ui.book.explore.ExploreShowActivity
 import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.rss.article.RssSortActivity
+import io.legado.app.ui.rss.navigation.RssMainNavContract
 import io.legado.app.ui.widget.dialog.PhotoDialog
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.showDialogFragment
@@ -127,7 +127,13 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
                     }
                     val sourceUrl = toSource.sourceUrl
                     withContext(Main) {
-                        RssSortActivity.start(activity, sortUrl, sourceUrl)
+                        activity.startActivity(
+                            RssMainNavContract.createRssSortIntent(
+                                context = activity,
+                                sourceUrl = sourceUrl,
+                                sortUrl = sortUrl
+                            )
+                        )
                     }
                 }
 
@@ -148,7 +154,14 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
                     )
                     appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录
                     withContext(Main) {
-                        ReadRssActivity.start(activity, title, url, sourceUrl)
+                        activity.startActivity(
+                            RssMainNavContract.createRssReadIntent(
+                                context = activity,
+                                title = title,
+                                origin = sourceUrl,
+                                openUrl = link
+                            )
+                        )
                     }
                 }
 

+ 400 - 0
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -0,0 +1,400 @@
+@file:Suppress("DEPRECATION")
+
+package io.legado.app.ui.rss.read
+
+import android.annotation.SuppressLint
+import android.net.Uri
+import android.net.http.SslError
+import android.os.SystemClock
+import android.view.View
+import android.webkit.JavascriptInterface
+import android.webkit.SslErrorHandler
+import android.webkit.URLUtil
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.LocalActivity
+import androidx.appcompat.app.AlertDialog
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Login
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CleaningServices
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.script.rhino.runScriptWithContext
+import io.legado.app.R
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppLog
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.http.CookieManager
+import io.legado.app.lib.dialogs.SelectItem
+import io.legado.app.lib.dialogs.selector
+import io.legado.app.model.Download
+import io.legado.app.ui.association.OnLineImportActivity
+import io.legado.app.ui.config.otherConfig.OtherConfig
+import io.legado.app.ui.login.SourceLoginActivity
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.TopBarActionButton
+import io.legado.app.ui.widget.components.button.TopBarNavigationButton
+import io.legado.app.ui.widget.components.card.GlassCard
+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.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.isTrue
+import io.legado.app.utils.keepScreenOn
+import io.legado.app.utils.longSnackbar
+import io.legado.app.utils.openUrl
+import io.legado.app.utils.setDarkeningAllowed
+import io.legado.app.utils.share
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.launch
+import org.apache.commons.text.StringEscapeUtils
+import org.jsoup.Jsoup
+import java.net.URLDecoder
+import org.koin.androidx.compose.koinViewModel
+
+@SuppressLint("SetJavaScriptEnabled")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RssReadRouteScreen(
+    title: String?,
+    origin: String,
+    link: String?,
+    openUrl: String?,
+    onBackClick: () -> Unit,
+    viewModel: ReadRssViewModel = koinViewModel()
+) {
+    val context = LocalContext.current
+    val activity = LocalActivity.current
+    val appCompatActivity = activity as? androidx.appcompat.app.AppCompatActivity
+    val scope = rememberCoroutineScope()
+    val defaultTopBarTitle = stringResource(R.string.rss)
+
+    var pageTitle by remember(title, defaultTopBarTitle) {
+        mutableStateOf(title?.takeIf { it.isNotBlank() } ?: defaultTopBarTitle)
+    }
+    var webProgress by remember { mutableIntStateOf(100) }
+    var showMenu by remember { mutableStateOf(false) }
+    var showRedirectMenu by remember { mutableStateOf(false) }
+
+    var webView by remember { mutableStateOf<WebView?>(null) }
+
+    var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
+
+    var showFavoriteSheet by remember { mutableStateOf(false) }
+    var favoriteTitle by remember { mutableStateOf("") }
+    var favoriteGroup by remember { mutableStateOf("") }
+
+    val content by viewModel.contentState.collectAsStateWithLifecycle()
+    val analyzeUrl by viewModel.urlState.collectAsStateWithLifecycle()
+    val rssStar by viewModel.rssStarState.collectAsStateWithLifecycle()
+    val isSpeaking by viewModel.isSpeakingState.collectAsStateWithLifecycle()
+    val fallbackUserAgent = OtherConfig.userAgent
+
+    LaunchedEffect(origin, link, openUrl, title) {
+        viewModel.initData(
+            ReadRssArgs(
+                title = title,
+                origin = origin,
+                link = link,
+                openUrl = openUrl
+            )
+        )
+    }
+
+    LaunchedEffect(viewModel.rssSource?.redirectPolicy) {
+        redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
+    }
+
+    LaunchedEffect(content, webView, fallbackUserAgent) {
+        val body = content ?: return@LaunchedEffect
+        val currentWebView = webView ?: return@LaunchedEffect
+        currentWebView.settings.userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: fallbackUserAgent
+        val article = viewModel.rssArticle ?: return@LaunchedEffect
+        val url = NetworkUtils.getAbsoluteURL(article.origin, article.link)
+        val html = viewModel.clHtml(body)
+        if (viewModel.rssSource?.loadWithBaseUrl == true) {
+            currentWebView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
+        } else {
+            currentWebView.loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url)
+        }
+    }
+
+    LaunchedEffect(analyzeUrl, webView) {
+        val url = analyzeUrl ?: return@LaunchedEffect
+        val currentWebView = webView ?: return@LaunchedEffect
+        CookieManager.applyToWebView(url.url)
+        currentWebView.settings.userAgentString = url.getUserAgent()
+        currentWebView.loadUrl(url.url, url.headerMap)
+    }
+
+    BackHandler {
+        when {
+            webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size ?: 0) > 1 -> webView?.goBack()
+            else -> onBackClick()
+        }
+    }
+
+    Column(modifier = Modifier.fillMaxSize()) {
+        GlassMediumFlexibleTopAppBar(
+            title = pageTitle.ifBlank { defaultTopBarTitle },
+            navigationIcon = {
+                TopBarNavigationButton(
+                    onClick = onBackClick,
+                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                )
+            },
+            scrollBehavior = null,
+            actions = {
+                TopBarActionButton(
+                    onClick = { viewModel.refresh { webView?.reload() } },
+                    imageVector = Icons.Default.Refresh,
+                    contentDescription = stringResource(R.string.refresh)
+                )
+                TopBarActionButton(
+                    onClick = {
+                        viewModel.addFavorite()
+                        favoriteTitle = viewModel.rssArticle?.title.orEmpty()
+                        favoriteGroup = viewModel.rssArticle?.group.orEmpty()
+                        showFavoriteSheet = true
+                    },
+                    imageVector = Icons.Default.Star,
+                    contentDescription = stringResource(R.string.favorite)
+                )
+                TopBarActionButton(
+                    onClick = { showMenu = true },
+                    imageVector = Icons.Default.MoreVert,
+                    contentDescription = "Menu"
+                )
+                RoundDropdownMenu(
+                    expanded = showMenu,
+                    onDismissRequest = { showMenu = false }
+                ) { dismiss ->
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.share),
+                        leadingIcon = { MenuItemIcon(Icons.Default.Share) },
+                        onClick = {
+                            dismiss()
+                            webView?.url?.let {
+                                context.share(it)
+                            } ?: viewModel.rssArticle?.let {
+                                context.share(it.link)
+                            } ?: context.toastOnUi(R.string.null_url)
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource(
+                            R.string.read_aloud
+                        ),
+                        leadingIcon = {
+                            MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp)
+                        },
+                        onClick = {
+                            dismiss()
+                            if (isSpeaking) {
+                                viewModel.stopReadAloud()
+                            } else {
+                                webView?.evaluateJavascript("document.documentElement.outerHTML") {
+                                    val html = StringEscapeUtils.unescapeJson(it)
+                                        .replace("^\"|\"$".toRegex(), "")
+                                    viewModel.readAloud(
+                                        Jsoup.parse(html)
+                                            .text()
+                                    )
+                                }
+                            }
+                        }
+                    )
+                    if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.login),
+                            leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
+                            onClick = {
+                                dismiss()
+                                context.startActivity<SourceLoginActivity> {
+                                    putExtra("type", "rssSource")
+                                    putExtra("key", viewModel.rssSource?.sourceUrl)
+                                }
+                            }
+                        )
+                    }
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.redirect_policy),
+                        onClick = { showRedirectMenu = true }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.open_in_browser),
+                        leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
+                        onClick = {
+                            dismiss()
+                            webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
+                        }
+                    )
+                }
+
+                RoundDropdownMenu(
+                    expanded = showRedirectMenu,
+                    onDismissRequest = { showRedirectMenu = false }
+                ) { dismiss ->
+                    RedirectPolicy.entries.forEach { policy ->
+                        RoundDropdownMenuItem(
+                            text = policy.title(),
+                            onClick = {
+                                dismiss()
+                                showMenu = false
+                                viewModel.rssSource?.let { source ->
+                                    viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
+                                    redirectPolicy = policy
+                                }
+                                context.toastOnUi("重定向策略已更新")
+                            },
+                            trailingIcon = {
+                                if (policy == redirectPolicy) {
+                                    Icon(Icons.Default.Check, null)
+                                }
+                            }
+                        )
+                    }
+                }
+            }
+        )
+
+        Box(modifier = Modifier.fillMaxSize()) {
+            VisibleWebViewCompose(
+                modifier = Modifier.fillMaxSize(),
+                onCreated = { createdWebView ->
+                    webView = createdWebView
+                    configureRssReadWebView(
+                        webView = createdWebView,
+                        context = context,
+                        activity = activity,
+                        appCompatActivity = appCompatActivity,
+                        viewModel = viewModel,
+                        initialTitle = title,
+                        redirectPolicyProvider = { redirectPolicy },
+                        callbacks = RssReadWebControllerCallbacks(
+                            onProgressChanged = { webProgress = it },
+                            onPageTitleResolved = { resolved ->
+                                pageTitle = resolved.ifBlank { defaultTopBarTitle }
+                            }
+                        )
+                    )
+                }
+            )
+            if (webProgress in 0..99) {
+                LinearProgressIndicator(
+                    progress = { webProgress / 100f },
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+        }
+    }
+
+    FavoriteEditSheet(
+        show = showFavoriteSheet,
+        titleValue = favoriteTitle,
+        groupValue = favoriteGroup,
+        onTitleChange = { favoriteTitle = it },
+        onGroupChange = { favoriteGroup = it },
+        onDismissRequest = { showFavoriteSheet = false },
+        onSave = {
+            viewModel.updateFavorite(favoriteTitle, favoriteGroup)
+            showFavoriteSheet = false
+        },
+        onDelete = {
+            viewModel.delFavorite()
+            showFavoriteSheet = false
+        }
+    )
+}
+
+@Composable
+private fun FavoriteEditSheet(
+    show: Boolean,
+    titleValue: String,
+    groupValue: String,
+    onTitleChange: (String) -> Unit,
+    onGroupChange: (String) -> Unit,
+    onDismissRequest: () -> Unit,
+    onSave: () -> Unit,
+    onDelete: () -> Unit
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.favorite),
+        endAction = {
+            SmallIconButton(
+                onClick = onDelete,
+                imageVector = Icons.Default.CleaningServices,
+                contentDescription = stringResource(R.string.delete)
+            )
+        }
+    ) {
+
+        Column(modifier = Modifier.padding(12.dp)) {
+            AppTextField(
+                value = titleValue,
+                onValueChange = onTitleChange,
+                label = stringResource(R.string.title),
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth()
+            )
+            AppTextField(
+                value = groupValue,
+                onValueChange = onGroupChange,
+                label = stringResource(R.string.group_name),
+                singleLine = true,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(top = 8.dp)
+            )
+        }
+
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.action_save),
+            leadingIcon = { MenuItemIcon(Icons.Default.Check) },
+            onClick = onSave,
+            modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
+        )
+    }
+}

+ 267 - 0
app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt

@@ -0,0 +1,267 @@
+@file:Suppress("DEPRECATION")
+
+package io.legado.app.ui.rss.read
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.net.Uri
+import android.net.http.SslError
+import android.os.SystemClock
+import android.view.View
+import android.webkit.JavascriptInterface
+import android.webkit.SslErrorHandler
+import android.webkit.URLUtil
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.net.toUri
+import androidx.lifecycle.lifecycleScope
+import com.script.rhino.runScriptWithContext
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.help.config.AppConfig
+import io.legado.app.lib.dialogs.SelectItem
+import io.legado.app.lib.dialogs.selector
+import io.legado.app.model.Download
+import io.legado.app.ui.association.OnLineImportActivity
+import io.legado.app.utils.isTrue
+import io.legado.app.utils.longSnackbar
+import io.legado.app.utils.openUrl
+import io.legado.app.utils.setDarkeningAllowed
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
+import java.net.URLDecoder
+
+internal data class RssReadWebControllerCallbacks(
+    val onProgressChanged: (Int) -> Unit,
+    val onPageTitleResolved: (String) -> Unit
+)
+
+@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
+internal fun configureRssReadWebView(
+    webView: VisibleWebView,
+    context: Context,
+    activity: Activity?,
+    appCompatActivity: AppCompatActivity?,
+    viewModel: ReadRssViewModel,
+    initialTitle: String?,
+    redirectPolicyProvider: () -> RedirectPolicy,
+    callbacks: RssReadWebControllerCallbacks
+) {
+    webView.webChromeClient = object : WebChromeClient() {
+        override fun onProgressChanged(view: WebView?, newProgress: Int) {
+            callbacks.onProgressChanged(newProgress)
+        }
+    }
+
+    webView.webViewClient = object : WebViewClient() {
+        private var lastUrl: String? = null
+
+        override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+            val targetUri = request.url
+            if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
+                return handleCustomScheme(targetUri)
+            }
+            val currentUrl = lastUrl ?: view.url
+            val targetUrl = request.url.toString()
+            lastUrl = targetUrl
+            if (!request.isForMainFrame) return false
+            if (handleRedirect(view, currentUrl, targetUrl)) return true
+            return handleCustomScheme(request.url)
+        }
+
+        @Deprecated("Deprecated in Java")
+        override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+            val targetUri = url.toUri()
+            if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
+                return handleCustomScheme(targetUri)
+            }
+            val currentUrl = lastUrl ?: view.url
+            val targetUrl = url
+            lastUrl = targetUrl
+            if (handleRedirect(view, currentUrl, targetUrl)) return true
+            return handleCustomScheme(targetUri)
+        }
+
+        private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
+            val fromHost = fromUrl?.toUri()?.host
+            val toHost = toUrl.toUri().host
+            val crossOrigin = fromHost != null && toHost != null && fromHost != toHost
+
+            return when (redirectPolicyProvider()) {
+                RedirectPolicy.ALLOW_ALL -> false
+                RedirectPolicy.BLOCK_ALL -> {
+                    context.toastOnUi("已阻止重定向")
+                    true
+                }
+
+                RedirectPolicy.ASK_ALWAYS -> {
+                    askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
+                    true
+                }
+
+                RedirectPolicy.ASK_CROSS_ORIGIN -> {
+                    if (crossOrigin) {
+                        askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
+                        true
+                    } else false
+                }
+
+                RedirectPolicy.BLOCK_CROSS_ORIGIN -> {
+                    if (crossOrigin) {
+                        context.toastOnUi("已阻止跨域重定向")
+                        true
+                    } else false
+                }
+
+                RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> {
+                    if (crossOrigin) {
+                        context.toastOnUi("已阻止域外跳转")
+                        true
+                    } else {
+                        askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
+                        true
+                    }
+                }
+            }
+        }
+
+        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()
+        }
+
+        private fun handleCustomScheme(url: Uri): Boolean {
+            val source = viewModel.rssSource
+            val js = source?.shouldOverrideUrlLoading
+            if (!js.isNullOrBlank() && appCompatActivity != null) {
+                val t = SystemClock.uptimeMillis()
+                val result = kotlin.runCatching {
+                    runScriptWithContext(appCompatActivity.lifecycleScope.coroutineContext) {
+                        source.evalJS(js) {
+                            put("java", RssJsExtensions(appCompatActivity, source))
+                            put("url", url.toString())
+                        }.toString()
+                    }
+                }.onFailure {
+                    AppLog.put("${source.getTag()}: url跳转拦截js出错", it)
+                }.getOrNull()
+                if (SystemClock.uptimeMillis() - t > 30) {
+                    AppLog.put("${source.getTag()}: url跳转拦截js执行耗时过长")
+                }
+                if (result.isTrue()) return true
+            }
+
+            return when (url.scheme) {
+                "http", "https", "jsbridge" -> false
+                "legado", "yuedu" -> {
+                    context.startActivity<OnLineImportActivity> {
+                        data = url
+                    }
+                    true
+                }
+
+                else -> {
+                    val root = activity?.findViewById<View>(android.R.id.content)
+                    if (root != null) {
+                        root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
+                            context.openUrl(url)
+                        }
+                    } else {
+                        context.openUrl(url)
+                    }
+                    true
+                }
+            }
+        }
+
+        override fun onPageFinished(view: WebView, url: String?) {
+            super.onPageFinished(view, url)
+            view.title?.let { webTitle ->
+                if (
+                    webTitle != url &&
+                    webTitle != view.url &&
+                    webTitle.isNotBlank() &&
+                    url != "about:blank"
+                ) {
+                    callbacks.onPageTitleResolved(webTitle)
+                } else {
+                    callbacks.onPageTitleResolved(initialTitle.orEmpty())
+                }
+            }
+            viewModel.rssSource?.injectJs?.let {
+                if (it.isNotBlank()) {
+                    view.evaluateJavascript(it, null)
+                }
+            }
+        }
+
+        override fun onReceivedSslError(
+            view: WebView?,
+            handler: SslErrorHandler?,
+            error: SslError?
+        ) {
+            handler?.proceed()
+        }
+    }
+
+    webView.settings.apply {
+        mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+        domStorageEnabled = true
+        allowContentAccess = true
+        builtInZoomControls = true
+        displayZoomControls = false
+        setDarkeningAllowed(AppConfig.isNightTheme)
+        if (viewModel.rssSource?.enableJs == true) {
+            javaScriptEnabled = true
+        }
+    }
+
+    webView.addJavascriptInterface(object {
+        @JavascriptInterface
+        fun isNightTheme(): Boolean = AppConfig.isNightTheme
+    }, "thisActivity")
+
+    webView.setOnLongClickListener {
+        val hitTestResult = webView.hitTestResult
+        if (
+            hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
+            hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
+        ) {
+            hitTestResult.extra?.let { webPic ->
+                appCompatActivity?.selector(
+                    arrayListOf(
+                        SelectItem(context.getString(R.string.action_save), "save"),
+                    )
+                ) { _, charSequence, _ ->
+                    if (charSequence.value == "save") {
+                        viewModel.saveImage(webPic)
+                    }
+                }
+                return@setOnLongClickListener true
+            }
+        }
+        false
+    }
+
+    webView.setDownloadListener { url, _, contentDisposition, _, _ ->
+        var fileName = URLUtil.guessFileName(url, contentDisposition, null)
+        fileName = URLDecoder.decode(fileName, "UTF-8")
+        val root = appCompatActivity?.findViewById<View>(android.R.id.content)
+        if (root != null) {
+            root.longSnackbar(fileName, context.getString(R.string.action_download)) {
+                Download.start(context, url, fileName)
+            }
+        }
+    }
+}

+ 54 - 28
app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt

@@ -6,10 +6,13 @@ import android.util.AttributeSet
 import android.view.ActionMode
 import android.view.Menu
 import android.view.MenuItem
-import android.view.View
 import android.webkit.JavascriptInterface
 import android.webkit.WebView
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
 import io.legado.app.R
 import io.legado.app.ui.dict.DictDialog
 import io.legado.app.utils.toastOnUi
@@ -20,6 +23,8 @@ class VisibleWebView(
     attrs: AttributeSet? = null
 ) : WebView(context, attrs) {
 
+    private var lastSelectedText: String = ""
+
     init {
         settings.javaScriptEnabled = true
         settings.domStorageEnabled = true
@@ -32,30 +37,26 @@ class VisibleWebView(
         }, "TextSelectionBridge")
 
         val js = """
-        document.addEventListener('selectionchange', function() {
-            const text = window.getSelection().toString();
-            if (text) {
-                TextSelectionBridge.onTextSelected(text);
-            }
-        });
-    """
+            document.addEventListener('selectionchange', function() {
+                const text = window.getSelection().toString();
+                if (text) {
+                    TextSelectionBridge.onTextSelected(text);
+                }
+            });
+        """.trimIndent()
         evaluateJavascript(js, null)
     }
 
-    private var lastSelectedText: String = ""
-
     override fun onWindowVisibilityChanged(visibility: Int) {
         super.onWindowVisibilityChanged(VISIBLE)
     }
 
     override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
-        val wrappedCallback = createWrappedCallback(callback)
-        return super.startActionMode(wrappedCallback)
+        return super.startActionMode(createWrappedCallback(callback))
     }
 
     override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
-        val wrappedCallback = createWrappedCallback(callback)
-        return super.startActionMode(wrappedCallback, type)
+        return super.startActionMode(createWrappedCallback(callback), type)
     }
 
     private fun createWrappedCallback(original: ActionMode.Callback?): ActionMode.Callback {
@@ -63,7 +64,7 @@ class VisibleWebView(
             override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
                 val result = original?.onCreateActionMode(mode, menu) ?: false
                 menu.add(Menu.NONE, MENU_ID_DICT, 0, R.string.dict)
-                getSelectedText { /* 触发缓存*/ }
+                getSelectedText { }
                 return result
             }
 
@@ -73,7 +74,7 @@ class VisibleWebView(
             }
 
             override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
-                when (item.itemId) {
+                return when (item.itemId) {
                     MENU_ID_DICT -> {
                         postDelayed({
                             getSelectedText { selectedText ->
@@ -85,13 +86,13 @@ class VisibleWebView(
                             }
                         }, 200)
                         mode.finish()
-                        return true
+                        true
                     }
-                    else -> return original?.onActionItemClicked(mode, item) ?: false
+
+                    else -> original?.onActionItemClicked(mode, item) ?: false
                 }
             }
 
-
             override fun onDestroyActionMode(mode: ActionMode) {
                 original?.onDestroyActionMode(mode)
             }
@@ -120,17 +121,42 @@ class VisibleWebView(
     }
 
     private fun showDictDialog(selectedText: String) {
-        val activity = context as? AppCompatActivity
-        activity?.let {
-            val dialog = DictDialog(selectedText)
-            it.supportFragmentManager.beginTransaction()
-                .add(dialog, "DictDialog")
-                .commitAllowingStateLoss()
-        }
+        val activity = context as? AppCompatActivity ?: return
+        val dialog = DictDialog(selectedText)
+        activity.supportFragmentManager.beginTransaction()
+            .add(dialog, "DictDialog")
+            .commitAllowingStateLoss()
     }
 
     companion object {
         private const val MENU_ID_DICT = 1001
     }
-
-}
+}
+
+@Composable
+fun VisibleWebViewCompose(
+    modifier: Modifier = Modifier,
+    onCreated: (VisibleWebView) -> Unit,
+    onDestroyed: (() -> Unit)? = null
+) {
+    var webViewRef: VisibleWebView? = null
+    AndroidView(
+        modifier = modifier,
+        factory = { context ->
+            VisibleWebView(context).also {
+                webViewRef = it
+                onCreated(it)
+            }
+        },
+        update = {
+            webViewRef = it
+        }
+    )
+    DisposableEffect(Unit) {
+        onDispose {
+            onDestroyed?.invoke()
+            webViewRef?.destroy()
+            webViewRef = null
+        }
+    }
+}

+ 0 - 92
app/src/main/res/layout/activity_rss_artivles.xml

@@ -1,92 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <com.google.android.material.appbar.AppBarLayout
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="@android:color/transparent"
-        android:fitsSystemWindows="true"
-        app:liftOnScroll="false"
-        android:stateListAnimator="@null">
-
-        <com.google.android.material.appbar.CollapsingToolbarLayout
-            android:id="@+id/ctl_bar"
-            android:layout_width="match_parent"
-            android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
-            style="?attr/collapsingToolbarLayoutMediumStyle"
-            app:collapsedTitleTextAppearance="?attr/textAppearanceTitleLarge"
-            app:expandedTitleTextAppearance="?attr/textAppearanceHeadlineLarge"
-            app:titleCollapseMode="scale"
-            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
-
-            <com.google.android.material.appbar.MaterialToolbar
-                android:theme="@style/ThemeOverlay.Material3.Toolbar.CircleNavigationButton"
-                android:id="@+id/top_bar"
-                android:layout_width="match_parent"
-                android:layout_height="?attr/actionBarSize"
-                app:navigationIcon="@drawable/ic_arrow_back"
-                app:title="@string/rss"
-                app:layout_collapseMode="pin" />
-
-        </com.google.android.material.appbar.CollapsingToolbarLayout>
-
-        <LinearLayout
-            android:id="@+id/ll_tab"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal"
-            android:gravity="center_vertical"
-            android:paddingStart="16dp"
-            android:paddingEnd="10dp">
-
-            <FrameLayout
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:layout_weight="1"
-                android:clipToPadding="false"
-                android:clipChildren="false">
-
-                <com.google.android.material.tabs.TabLayout
-                    android:id="@+id/tab_layout"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:background="@android:color/transparent"
-                    app:tabMode="scrollable"
-                    android:scrollbars="none" />
-
-                <View
-                    android:id="@+id/tab_right_fade"
-                    android:layout_width="40dp"
-                    android:layout_height="match_parent"
-                    android:layout_gravity="end"
-                    android:background="@drawable/tab_right_fade" />
-
-            </FrameLayout>
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_expand_tabs"
-                android:layout_width="40dp"
-                android:layout_height="40dp"
-                android:checkable="true"
-                app:iconGravity="textStart"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                app:icon="@drawable/m3_split_button_chevron_avd" />
-
-        </LinearLayout>
-
-    </com.google.android.material.appbar.AppBarLayout>
-
-    <androidx.viewpager2.widget.ViewPager2
-        android:id="@+id/view_pager"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        app:layout_behavior="@string/appbar_scrolling_view_behavior"
-        tools:ignore="SpeakableTextPresentCheck" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 0 - 41
app/src/main/res/layout/activity_rss_read.xml

@@ -1,41 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:orientation="vertical">
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/ll_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:orientation="vertical">
-
-        <io.legado.app.ui.widget.TitleBar
-            android:id="@+id/title_bar"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:layout_constraintTop_toTopOf="parent" />
-
-        <io.legado.app.ui.rss.read.VisibleWebView
-            android:id="@+id/web_view"
-            android:layout_width="match_parent"
-            android:layout_height="0dp"
-            app:layout_constraintTop_toBottomOf="@+id/title_bar"
-            app:layout_constraintBottom_toBottomOf="parent" />
-
-        <com.google.android.material.progressindicator.LinearProgressIndicator
-            android:id="@+id/progress_bar"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:progress="100"
-            app:layout_constraintTop_toTopOf="@id/web_view" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-
-    <FrameLayout
-        android:id="@+id/custom_web_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent" />
-
-</FrameLayout>

+ 0 - 104
app/src/main/res/layout/dialog_rss_favorite_config.xml

@@ -1,104 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/vw_bg"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:layout_gravity="center"
-    android:orientation="vertical"
-    tools:ignore="UselessParent">
-
-    <androidx.appcompat.widget.Toolbar
-        android:id="@+id/tool_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:theme="?attr/actionBarStyle"
-        app:title="@string/favorite" />
-
-    <androidx.core.widget.NestedScrollView
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_weight="1"
-        android:overScrollMode="ifContentScrolls">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:padding="16dp">
-
-            <io.legado.app.ui.widget.text.TextInputLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:paddingTop="3dp">
-
-                <io.legado.app.lib.theme.view.ThemeEditText
-                    android:id="@+id/edit_title"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:hint="@string/title"
-                    tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
-
-            </io.legado.app.ui.widget.text.TextInputLayout>
-
-            <io.legado.app.ui.widget.text.TextInputLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:paddingTop="3dp">
-
-                <io.legado.app.lib.theme.view.ThemeEditText
-                    android:id="@+id/edit_group"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:hint="@string/group_name"
-                    tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
-
-            </io.legado.app.ui.widget.text.TextInputLayout>
-
-        </LinearLayout>
-
-    </androidx.core.widget.NestedScrollView>
-
-    <com.google.android.flexbox.FlexboxLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingLeft="12dp"
-        android:paddingRight="12dp"
-        app:flexWrap="wrap"
-        app:justifyContent="space_between">
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/tv_footer_left"
-            style="@style/Widget.Material3.Button.OutlinedButton"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:text="@string/delete"
-            tools:ignore="RtlHardcoded" />
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/tv_cancel"
-                style="@style/Widget.Material3.Button.TonalButton"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginEnd="8dp"
-                android:text="@string/cancel"
-                tools:ignore="RtlHardcoded" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/tv_ok"
-                style="@style/Widget.Material3.Button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/ok"
-                tools:ignore="RtlHardcoded" />
-
-        </LinearLayout>
-
-    </com.google.android.flexbox.FlexboxLayout>
-</LinearLayout>

+ 0 - 14
app/src/main/res/layout/fragment_rss_articles.xml

@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:id="@+id/refresh_layout"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <io.legado.app.ui.widget.recycler.RecyclerViewAtPager2
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:padding="4dp"
-        android:clipToPadding="false" />
-
-</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

+ 0 - 57
app/src/main/res/layout/item_rss_article.xml

@@ -1,57 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_marginVertical="8dp"
-    android:layout_marginHorizontal="8dp"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    app:strokeWidth="0dp"
-    app:cardBackgroundColor="?attr/colorSurfaceContainer">
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:padding="8dp"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackground">
-
-    <TextView
-        android:id="@+id/tv_title"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        style="@style/ChapterNameText"
-        android:ellipsize="end"
-        android:text="@string/app_name"
-        app:layout_constraintBottom_toTopOf="@+id/tv_pub_date"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toLeftOf="@+id/image_view"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <TextView
-        android:id="@+id/tv_pub_date"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:layout_marginTop="8dp"
-        android:text="@string/app_name"
-        android:textSize="12sp"
-        android:textStyle="italic"
-        android:maxLines="1"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toLeftOf="@+id/image_view" />
-
-    <ImageView
-        android:id="@+id/image_view"
-        android:layout_width="110dp"
-        android:layout_height="68dp"
-        android:contentDescription="@string/img_cover"
-        android:paddingLeft="16dp"
-        android:visibility="gone"
-        android:scaleType="centerCrop"
-        android:src="@drawable/image_rss_article"
-        app:layout_constraintRight_toRightOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-</com.google.android.material.card.MaterialCardView>

+ 0 - 56
app/src/main/res/layout/item_rss_article_1.xml

@@ -1,56 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_margin="4dp"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    app:strokeWidth="0dp"
-    app:cardBackgroundColor="?attr/colorSurfaceContainer">
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:padding="8dp"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackground">
-
-    <ImageView
-        android:id="@+id/image_view"
-        android:layout_width="0dp"
-        android:layout_height="220dp"
-        android:contentDescription="@string/loading"
-        android:visibility="visible"
-        android:scaleType="centerCrop"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-    <TextView
-        android:id="@+id/tv_title"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginVertical="4dp"
-        android:maxLines="2"
-        android:ellipsize="end"
-        android:text="@string/app_name"
-        android:textSize="15sp"
-        android:textStyle="bold"
-        app:layout_constraintTop_toBottomOf="@+id/image_view"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent" />
-
-    <TextView
-        android:id="@+id/tv_pub_date"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:text="@string/app_name"
-        android:textSize="11sp"
-        android:maxLines="1"
-        app:layout_constraintTop_toBottomOf="@+id/tv_title"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        tools:ignore="SmallSp" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-</com.google.android.material.card.MaterialCardView>

+ 0 - 58
app/src/main/res/layout/item_rss_article_2.xml

@@ -1,58 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_margin="4dp"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    app:strokeWidth="0dp"
-    android:background="?attr/selectableItemBackground">
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:padding="8dp"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="?attr/colorSurfaceContainer">
-
-        <ImageView
-            android:id="@+id/image_view"
-            android:layout_width="0dp"
-            android:layout_height="240dp"
-            android:contentDescription="@string/loading"
-            android:visibility="visible"
-            android:scaleType="centerCrop"
-            android:background="@drawable/bg_img_border"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent"
-            tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-        <TextView
-            android:id="@+id/tv_title"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:maxLines="2"
-            android:ellipsize="end"
-            android:text="@string/app_name"
-            android:textSize="13sp"
-            android:paddingTop="8dp"
-            app:layout_constraintTop_toBottomOf="@+id/image_view"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent" />
-
-        <TextView
-            android:id="@+id/tv_pub_date"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:text="@string/app_name"
-            android:textSize="11sp"
-            android:maxLines="1"
-            android:paddingTop="4dp"
-            android:paddingBottom="2dp"
-            app:layout_constraintTop_toBottomOf="@+id/tv_title"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent"
-            tools:ignore="SmallSp" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-</com.google.android.material.card.MaterialCardView>

+ 0 - 65
app/src/main/res/layout/item_rss_article_3.xml

@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_margin="4dp"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    app:strokeColor="?attr/colorSurfaceContainer"
-    app:cardBackgroundColor="?attr/colorSurfaceContainer">
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackground">
-
-        <com.google.android.material.card.MaterialCardView
-            android:id="@+id/cd_img"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:cardCornerRadius="0dp"
-            app:strokeColor="?attr/colorSurfaceContainer"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent">
-            <ImageView
-                android:id="@+id/image_view"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:minHeight="80dp"
-                android:backgroundTint="?attr/colorSurfaceContainer"
-                android:contentDescription="@string/loading"
-                android:visibility="visible"
-                 />
-        </com.google.android.material.card.MaterialCardView>
-
-        <TextView
-            android:id="@+id/tv_title"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:maxLines="3"
-            android:ellipsize="end"
-            android:text="@string/app_name"
-            android:textSize="14sp"
-            android:textStyle="bold"
-            android:layout_marginHorizontal="8dp"
-            android:paddingTop="4dp"
-            app:layout_constraintTop_toBottomOf="@+id/cd_img"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent" />
-
-        <TextView
-            android:id="@+id/tv_pub_date"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:text="@string/app_name"
-            android:textSize="11sp"
-            android:paddingTop="4dp"
-            android:paddingBottom="10dp"
-            android:layout_marginHorizontal="8dp"
-            app:layout_constraintTop_toBottomOf="@+id/tv_title"
-            app:layout_constraintLeft_toLeftOf="parent"
-            app:layout_constraintRight_toRightOf="parent"
-            tools:ignore="SmallSp" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-</com.google.android.material.card.MaterialCardView>

+ 0 - 23
app/src/main/res/menu/menu_redirect_policy_submenu.xml

@@ -1,23 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android">
-    <group android:checkableBehavior="single">
-        <item
-            android:id="@+id/menu_redirect_allow_all"
-            android:title="@string/redirect_allow_all" />
-        <item
-            android:id="@+id/menu_redirect_ask_always"
-            android:title="@string/redirect_ask_always" />
-        <item
-            android:id="@+id/menu_redirect_ask_cross_origin"
-            android:title="@string/redirect_ask_cross_origin" />
-        <item
-            android:id="@+id/menu_redirect_ask_same_domain_block_cross"
-            android:title="@string/redirect_ask_same_domain_block_cross" />
-        <item
-            android:id="@+id/menu_redirect_block_cross_origin"
-            android:title="@string/redirect_block_cross_origin" />
-        <item
-            android:id="@+id/menu_redirect_block_all"
-            android:title="@string/redirect_block_all" />
-    </group>
-</menu>

+ 0 - 45
app/src/main/res/menu/rss_articles.xml

@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/menu_login"
-        android:title="@string/login"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_refresh_sort"
-        android:title="@string/refresh_sort"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_set_source_variable"
-        android:title="@string/set_source_variable"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_edit_source"
-        android:title="@string/edit_source"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_switch_layout"
-        android:title="@string/switchLayout"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_read_record"
-        android:title="@string/read_record"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_clear"
-        android:title="@string/clear"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_redirect_policy"
-        android:title="@string/redirect_policy"
-        app:showAsAction="never" />
-
-</menu>

+ 0 - 51
app/src/main/res/menu/rss_read.xml

@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools">
-    <item
-        android:id="@+id/menu_rss_refresh"
-        android:title="@string/refresh"
-        android:icon="@drawable/ic_refresh"
-        app:showAsAction="always" />
-
-    <item
-        android:id="@+id/menu_rss_star"
-        android:title="@string/favorite"
-        android:icon="@drawable/ic_star"
-        app:showAsAction="always" />
-
-    <item
-        android:id="@+id/menu_full_screen"
-        android:icon="@drawable/ic_fullscreen"
-        android:title="@string/full_screen"
-        app:showAsAction="always"
-        tools:ignore="AlwaysShowAction" />
-
-    <item
-        android:id="@+id/menu_share_it"
-        android:title="@string/share"
-        android:icon="@drawable/ic_share"
-        app:showAsAction="ifRoom" />
-
-    <item
-        android:id="@+id/menu_aloud"
-        android:title="@string/read_aloud"
-        android:icon="@drawable/ic_volume_up"
-        app:showAsAction="ifRoom" />
-
-    <item
-        android:id="@+id/menu_login"
-        android:title="@string/login"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_redirect_policy"
-        android:title="@string/redirect_policy"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_browser_open"
-        android:title="@string/open_in_browser"
-        app:showAsAction="never" />
-
-</menu>

+ 0 - 12
app/src/main/res/menu/rss_read_record.xml

@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_clear"
-        android:title="@string/clear"
-        app:showAsAction="always" />
-
-</menu>