From ca70dcaec9e1b23c3ede8d95750d9972030b8e6b Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:14:59 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E9=98=85=E8=AF=BB=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E6=9C=89bug=E5=96=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 12 - .../io/legado/app/data/entities/BaseSource.kt | 16 +- .../main/java/io/legado/app/di/appModule.kt | 7 + .../io/legado/app/ui/main/MainActivity.kt | 63 +- .../java/io/legado/app/ui/main/MainScreen.kt | 9 +- .../io/legado/app/ui/main/rss/RssScreen.kt | 21 +- .../ui/rss/article/BaseRssArticlesAdapter.kt | 16 - .../app/ui/rss/article/MainRouteRssSort.kt | 12 + .../app/ui/rss/article/ReadRecordDialog.kt | 88 --- .../app/ui/rss/article/RssArticlesAdapter.kt | 91 --- .../app/ui/rss/article/RssArticlesAdapter1.kt | 90 --- .../app/ui/rss/article/RssArticlesAdapter2.kt | 91 --- .../app/ui/rss/article/RssArticlesAdapter3.kt | 91 --- .../app/ui/rss/article/RssArticlesCompose.kt | 515 ++++++++++++++ .../app/ui/rss/article/RssArticlesFragment.kt | 182 ++--- .../ui/rss/article/RssArticlesViewModel.kt | 116 +++- .../app/ui/rss/article/RssSortActivity.kt | 265 -------- .../app/ui/rss/article/RssSortRouteScreen.kt | 189 ++++++ .../app/ui/rss/article/RssSortScreen.kt | 364 ++++++++++ .../app/ui/rss/article/RssSortViewModel.kt | 27 +- .../ui/rss/favorites/RssFavoritesAdapter.kt | 91 --- .../ui/rss/favorites/RssFavoritesDialog.kt | 67 -- .../ui/rss/favorites/RssFavoritesScreen.kt | 15 +- .../ui/rss/navigation/RssMainNavContract.kt | 86 +++ .../app/ui/rss/read/MainRouteRssRead.kt | 12 + .../legado/app/ui/rss/read/ReadRssActivity.kt | 627 ------------------ .../app/ui/rss/read/ReadRssViewModel.kt | 164 +++-- .../legado/app/ui/rss/read/RedirectPolicy.kt | 27 + .../legado/app/ui/rss/read/RssJsExtensions.kt | 19 +- .../app/ui/rss/read/RssReadRouteScreen.kt | 400 +++++++++++ .../app/ui/rss/read/RssReadWebController.kt | 267 ++++++++ .../legado/app/ui/rss/read/VisibleWebView.kt | 80 ++- .../main/res/layout/activity_rss_artivles.xml | 92 --- app/src/main/res/layout/activity_rss_read.xml | 41 -- .../res/layout/dialog_rss_favorite_config.xml | 104 --- .../main/res/layout/fragment_rss_articles.xml | 14 - app/src/main/res/layout/item_rss_article.xml | 57 -- .../main/res/layout/item_rss_article_1.xml | 56 -- .../main/res/layout/item_rss_article_2.xml | 58 -- .../main/res/layout/item_rss_article_3.xml | 65 -- .../res/menu/menu_redirect_policy_submenu.xml | 23 - app/src/main/res/menu/rss_articles.xml | 45 -- app/src/main/res/menu/rss_read.xml | 51 -- app/src/main/res/menu/rss_read_record.xml | 12 - 44 files changed, 2291 insertions(+), 2447 deletions(-) delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/article/MainRouteRssSort.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/navigation/RssMainNavContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/read/MainRouteRssRead.kt delete mode 100644 app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/read/RedirectPolicy.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt delete mode 100644 app/src/main/res/layout/activity_rss_artivles.xml delete mode 100644 app/src/main/res/layout/activity_rss_read.xml delete mode 100644 app/src/main/res/layout/dialog_rss_favorite_config.xml delete mode 100644 app/src/main/res/layout/fragment_rss_articles.xml delete mode 100644 app/src/main/res/layout/item_rss_article.xml delete mode 100644 app/src/main/res/layout/item_rss_article_1.xml delete mode 100644 app/src/main/res/layout/item_rss_article_2.xml delete mode 100644 app/src/main/res/layout/item_rss_article_3.xml delete mode 100644 app/src/main/res/menu/menu_redirect_policy_submenu.xml delete mode 100644 app/src/main/res/menu/rss_articles.xml delete mode 100644 app/src/main/res/menu/rss_read.xml delete mode 100644 app/src/main/res/menu/rss_read_record.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa45dc91b..655aeb230 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -263,18 +263,6 @@ android:name=".ui.book.searchContent.SearchContentActivity" android:enableOnBackInvokedCallback="true" android:launchMode="singleTop" /> - - - - evalJS(it.substring(4)).toString() + it.startsWith("@js:", true) -> evalJS(it.substring(4))?.toString().orEmpty() it.startsWith("", true) -> evalJS( it.substring(4, it.lastIndexOf("<")) - ).toString() + )?.toString().orEmpty() else -> it } + if (json.isBlank()) { + return@let + } GSONStrict.fromJsonObject>(json).getOrNull()?.let { map -> putAll(map) } ?: GSON.fromJsonObject>(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) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 195ecddfe..758af911d 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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) } + diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 857c559df..25c6302d0 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/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() - private val routeEvents = MutableSharedFlow(extraBufferCapacity = 1) + private val routeEvents = MutableSharedFlow(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 { route -> + RssSortRouteScreen( + sourceUrl = route.sourceUrl, + initialSortUrl = route.sortUrl, + onBackClick = { navigateBack(backStack) } + ) + } + + entry { route -> + RssReadRouteScreen( + title = route.title, + origin = route.origin, + link = route.link, + openUrl = route.openUrl, + onBackClick = { navigateBack(backStack) } + ) + } } ) } - private fun navigateToRoute(backStack: MutableList, route: MainRoute) { + private fun navigateToRoute(backStack: MutableList, 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 { diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 11251605d..0cffb84a3 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt index 4fe05766d..fbc2460e8 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt +++ b/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 { - 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 { - putExtra("url", rssSource.sourceUrl) - } + onOpenSort(rssSource.sourceUrl, null, null) } } diff --git a/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt deleted file mode 100644 index 69eb4ccd6..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/BaseRssArticlesAdapter.kt +++ /dev/null @@ -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(context: Context, val callBack: CallBack) : - RecyclerAdapter(context) { - - interface CallBack { - val isGridLayout: Boolean - fun readRss(rssArticle: RssArticle) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/MainRouteRssSort.kt b/app/src/main/java/io/legado/app/ui/rss/article/MainRouteRssSort.kt new file mode 100644 index 000000000..6e09a1cc3 --- /dev/null +++ b/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 + diff --git a/app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt b/app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt deleted file mode 100644 index be7562926..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/ReadRecordDialog.kt +++ /dev/null @@ -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() - 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(context) { - - override fun getViewBinding(parent: ViewGroup): ItemRssReadRecordBinding { - return ItemRssReadRecordBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemRssReadRecordBinding, - item: RssReadRecord, - payloads: MutableList - ) { - binding.textTitle.text = item.title - binding.textRecord.text = item.record - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemRssReadRecordBinding) { - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt deleted file mode 100644 index 547e05205..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter.kt +++ /dev/null @@ -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(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 - ) { - 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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { - imageView.gone() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - 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) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt deleted file mode 100644 index de142b6d0..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter1.kt +++ /dev/null @@ -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(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 - ) { - 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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { - imageView.gone() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - 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) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt deleted file mode 100644 index 2fb6ab6d9..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter2.kt +++ /dev/null @@ -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(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 - ) { - 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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { - imageView.gone() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - 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) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt deleted file mode 100644 index 94533bfa8..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesAdapter3.kt +++ /dev/null @@ -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(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 - ) { - 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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - imageView.gone() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - 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) - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt new file mode 100644 index 000000000..bbc7eabdc --- /dev/null +++ b/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 + ) +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt index 29fd0e4a4..bfc9b2ef5 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesFragment.kt +++ b/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(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(R.layout.frag } } - private val binding by viewBinding(FragmentRssArticlesBinding::bind) - private val activityViewModel by activityViewModels() - override val viewModel by viewModels() - 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) - } - } + @Composable + private fun Content() { + val sortName = arguments?.getString("sortName").orEmpty() + val sortUrl = arguments?.getString("sortUrl").orEmpty() + + RssArticlesPage( + sortName = sortName, + sortUrl = sortUrl, + articleStyle = activityViewModel.currentArticleStyle(), + rssUrl = activityViewModel.url, + rssSource = activityViewModel.rssSource, + viewModel = viewModel, + onRead = ::readRss + ) } - private fun loadArticles() { - activityViewModel.rssSource?.let { - viewModel.loadArticles(it) - } - } - - 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) - } - } - } - - 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 { - 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 + ) + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt index 5fba65e8e..c30ae8672 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt +++ b/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() - val loadErrorLiveData = MutableLiveData() - var isLoading = true + + private val _loadState = MutableStateFlow(RssArticlesLoadState()) + val loadState: StateFlow = _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) { 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()) - } - } -} \ No newline at end of file + 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 +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt deleted file mode 100644 index 362ea227a..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortActivity.kt +++ /dev/null @@ -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(), - VariableDialog.Callback { - - override val binding by viewBinding(ActivityRssArtivlesBinding::inflate) - override val viewModel by viewModels() - - private var adapter: TabFragmentPageAdapter? = null - private val sortList = mutableListOf>() - 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 { - 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() - } - } - 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> - ) : 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 { - putExtra("sortUrl", sortUrl) - putExtra("sourceUrl", sourceUrl) - putExtra("key", key) - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt new file mode 100644 index 000000000..f4ad068fe --- /dev/null +++ b/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>>(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>(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 { + 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 + ) + ) + } + ) + } + ) +} diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt new file mode 100644 index 000000000..e1a4dcf4e --- /dev/null +++ b/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>, + preferredSortUrl: String?, + hasLogin: Boolean, + redirectPolicy: RedirectPolicy, + showReadRecordSheet: Boolean, + readRecords: List, + 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) -> 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, + 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) + ) + } + } + } + } + } + } +} + diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt index 2d208adc6..be7006cee 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortViewModel.kt +++ b/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> { + 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}") } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt deleted file mode 100644 index f6b5e525b..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesAdapter.kt +++ /dev/null @@ -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(context) { - - override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding { - return ItemRssArticleBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemRssArticleBinding, - item: RssStar, - payloads: MutableList - ) { - 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 { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target, - isFirstResource: Boolean - ): Boolean { - imageView.gone() - return false - } - - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target?, - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt deleted file mode 100644 index 8bd927a77..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesDialog.kt +++ /dev/null @@ -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() - - } - -} diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt index be95fa348..dfc7c866e 100644 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt +++ b/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 { - 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, diff --git a/app/src/main/java/io/legado/app/ui/rss/navigation/RssMainNavContract.kt b/app/src/main/java/io/legado/app/ui/rss/navigation/RssMainNavContract.kt new file mode 100644 index 000000000..2bfcbef05 --- /dev/null +++ b/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 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/MainRouteRssRead.kt b/app/src/main/java/io/legado/app/ui/rss/read/MainRouteRssRead.kt new file mode 100644 index 000000000..7c91b4887 --- /dev/null +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt deleted file mode 100644 index a0e06cd0e..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssActivity.kt +++ /dev/null @@ -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(), - 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() - - 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 { - 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 { - 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 { - putExtra("title", title ?: "") - putExtra("origin", origin) - putExtra("openUrl", url) - } - } - - private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt index f201b0187..322db2d92 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/ReadRssViewModel.kt +++ b/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() - val urlLiveData = MutableLiveData() - var rssStar: RssStar? = null - val upTtsMenuData = MutableLiveData() - val upStarMenuData = MutableLiveData() var headerMap: Map = emptyMap() + private val _contentState = MutableStateFlow(null) + val contentState: StateFlow = _contentState.asStateFlow() + + private val _urlState = MutableStateFlow(null) + val urlState: StateFlow = _urlState.asStateFlow() + + private val _isSpeakingState = MutableStateFlow(false) + val isSpeakingState: StateFlow = _isSpeakingState.asStateFlow() + + private val _rssStarState = MutableStateFlow(null) + val rssStarState: StateFlow = _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) - } - } 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) + } ?: 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 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() { - execute { - rssStar?.let { - appDb.rssStarDao.delete(it.origin, it.link) - rssStar = null - } ?: rssArticle?.toStar()?.let { - appDb.rssStarDao.insert(it) - rssStar = it - } - }.onSuccess { - upStarMenuData.postValue(true) - } - } - fun addFavorite() { execute { - rssStar ?: rssArticle?.toStar()?.let { + _rssStarState.value ?: rssArticle?.toStar()?.let { appDb.rssStarDao.insert(it) - rssStar = it + _rssStarState.value = it } - }.onSuccess { - upStarMenuData.postValue(true) } } - fun updateFavorite() { + fun updateFavorite(title: String?, group: String?) { + rssArticle?.let { article -> + if (!title.isNullOrBlank()) { + article.title = title + } + group?.let { + article.group = it + } + } 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() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RedirectPolicy.kt b/app/src/main/java/io/legado/app/ui/rss/read/RedirectPolicy.kt new file mode 100644 index 000000000..bbb656c94 --- /dev/null +++ b/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 -> "拦截所有" + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt index 04a5ee1bf..a430cc1a2 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt +++ b/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 + ) + ) } } diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt new file mode 100644 index 000000000..1cb7b8dae --- /dev/null +++ b/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(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 { + 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) + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt new file mode 100644 index 000000000..a79c3c219 --- /dev/null +++ b/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 { + data = url + } + true + } + + else -> { + val root = activity?.findViewById(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(android.R.id.content) + if (root != null) { + root.longSnackbar(fileName, context.getString(R.string.action_download)) { + Download.start(context, url, fileName) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt b/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt index 22f21ca90..f1af32d19 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt +++ b/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 } +} -} \ No newline at end of file +@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 + } + } +} diff --git a/app/src/main/res/layout/activity_rss_artivles.xml b/app/src/main/res/layout/activity_rss_artivles.xml deleted file mode 100644 index bc9a75b19..000000000 --- a/app/src/main/res/layout/activity_rss_artivles.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_rss_read.xml b/app/src/main/res/layout/activity_rss_read.xml deleted file mode 100644 index 83f7a7dca..000000000 --- a/app/src/main/res/layout/activity_rss_read.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_rss_favorite_config.xml b/app/src/main/res/layout/dialog_rss_favorite_config.xml deleted file mode 100644 index a19fc63ce..000000000 --- a/app/src/main/res/layout/dialog_rss_favorite_config.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_rss_articles.xml b/app/src/main/res/layout/fragment_rss_articles.xml deleted file mode 100644 index b65c691f0..000000000 --- a/app/src/main/res/layout/fragment_rss_articles.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article.xml b/app/src/main/res/layout/item_rss_article.xml deleted file mode 100644 index 7fbf62095..000000000 --- a/app/src/main/res/layout/item_rss_article.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article_1.xml b/app/src/main/res/layout/item_rss_article_1.xml deleted file mode 100644 index 315b944ca..000000000 --- a/app/src/main/res/layout/item_rss_article_1.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article_2.xml b/app/src/main/res/layout/item_rss_article_2.xml deleted file mode 100644 index bf401d198..000000000 --- a/app/src/main/res/layout/item_rss_article_2.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_rss_article_3.xml b/app/src/main/res/layout/item_rss_article_3.xml deleted file mode 100644 index 15f05a732..000000000 --- a/app/src/main/res/layout/item_rss_article_3.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_redirect_policy_submenu.xml b/app/src/main/res/menu/menu_redirect_policy_submenu.xml deleted file mode 100644 index a41d5dbed..000000000 --- a/app/src/main/res/menu/menu_redirect_policy_submenu.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/rss_articles.xml b/app/src/main/res/menu/rss_articles.xml deleted file mode 100644 index 39cf6a491..000000000 --- a/app/src/main/res/menu/rss_articles.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/rss_read.xml b/app/src/main/res/menu/rss_read.xml deleted file mode 100644 index 39c14cfb9..000000000 --- a/app/src/main/res/menu/rss_read.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/rss_read_record.xml b/app/src/main/res/menu/rss_read_record.xml deleted file mode 100644 index 3d17348bb..000000000 --- a/app/src/main/res/menu/rss_read_record.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file