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

This commit is contained in:
HapeLee
2026-04-20 02:14:59 +08:00
parent 195a76de67
commit ca70dcaec9
44 changed files with 2291 additions and 2447 deletions
-12
View File
@@ -263,18 +263,6 @@
android:name=".ui.book.searchContent.SearchContentActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- RSS条目 -->
<activity
android:name=".ui.rss.article.RssSortActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- RSS阅读 -->
<activity
android:name=".ui.rss.read.ReadRssActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true"
android:launchMode="singleTop" />
<!-- 发现界面 -->
<activity
android:name=".ui.book.explore.ExploreShowActivity"
@@ -105,13 +105,16 @@ interface BaseSource : JsExtensions {
header?.let {
try {
val json = when {
it.startsWith("@js:", true) -> evalJS(it.substring(4)).toString()
it.startsWith("@js:", true) -> evalJS(it.substring(4))?.toString().orEmpty()
it.startsWith("<js>", true) -> evalJS(
it.substring(4, it.lastIndexOf("<"))
).toString()
)?.toString().orEmpty()
else -> it
}
if (json.isBlank()) {
return@let
}
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
putAll(map)
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
@@ -119,7 +122,12 @@ interface BaseSource : JsExtensions {
putAll(map)
}
} catch (e: Exception) {
AppLog.put("执行请求头规则出错\n$e", e)
val isEmptyJsonNoise = e.message?.contains("Empty JSON string", ignoreCase = true) == true ||
e.toString().contains("Empty JSON string", ignoreCase = true) ||
e.stackTraceToString().contains("Empty JSON string", ignoreCase = true)
if (!isEmptyJsonNoise) {
AppLog.put("执行请求头规则出错\n$e", e)
}
}
}
if (!has(AppConst.UA_NAME, true)) {
@@ -341,4 +349,4 @@ interface BaseSource : JsExtensions {
}
return RhinoScriptEngine.eval(jsStr, scope)
}
}
}
@@ -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)
}
@@ -48,6 +48,11 @@ import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.config.otherConfig.OtherConfigScreen
import io.legado.app.ui.config.readConfig.ReadConfigScreen
import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
import io.legado.app.ui.rss.article.MainRouteRssSort
import io.legado.app.ui.rss.navigation.RssMainNavContract
import io.legado.app.ui.rss.article.RssSortRouteScreen
import io.legado.app.ui.rss.read.MainRouteRssRead
import io.legado.app.ui.rss.read.RssReadRouteScreen
import io.legado.app.ui.welcome.WelcomeActivity
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.getPrefBoolean
@@ -98,7 +103,7 @@ open class MainActivity : BaseComposeActivity() {
}
private val viewModel by viewModel<MainViewModel>()
private val routeEvents = MutableSharedFlow<MainRoute>(extraBufferCapacity = 1)
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
@Serializable
private sealed interface MainRoute : NavKey
@@ -276,6 +281,16 @@ open class MainActivity : BaseComposeActivity() {
},
onNavigateToLocalImport = {
navigateToRoute(backStack, MainRouteImportLocal)
},
onNavigateToRssSort = { sourceUrl, sortUrl, key ->
navigateToRoute(
backStack,
MainRouteRssSort(
sourceUrl = sourceUrl,
sortUrl = sortUrl,
key = key
)
)
}
)
}
@@ -322,11 +337,29 @@ open class MainActivity : BaseComposeActivity() {
onBackClick = { navigateBack(backStack) }
)
}
entry<MainRouteRssSort> { route ->
RssSortRouteScreen(
sourceUrl = route.sourceUrl,
initialSortUrl = route.sortUrl,
onBackClick = { navigateBack(backStack) }
)
}
entry<MainRouteRssRead> { route ->
RssReadRouteScreen(
title = route.title,
origin = route.origin,
link = route.link,
openUrl = route.openUrl,
onBackClick = { navigateBack(backStack) }
)
}
}
)
}
private fun navigateToRoute(backStack: MutableList<NavKey>, route: MainRoute) {
private fun navigateToRoute(backStack: MutableList<NavKey>, route: NavKey) {
val currentRoute = backStack.lastOrNull()
if (currentRoute == route) return
@@ -367,6 +400,26 @@ open class MainActivity : BaseComposeActivity() {
backStack.add(route)
}
}
is MainRouteRssSort -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
is MainRouteRssRead -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
}
}
@@ -488,8 +541,10 @@ open class MainActivity : BaseComposeActivity() {
}
}
private fun resolveStartRoute(intent: Intent?): MainRoute {
return resolveStartRoute(intent?.getStringExtra(EXTRA_START_ROUTE))
private fun resolveStartRoute(intent: Intent?): NavKey {
val route = intent?.getStringExtra(EXTRA_START_ROUTE)
RssMainNavContract.resolveStartRoute(route, intent)?.let { return it }
return resolveStartRoute(route)
}
private fun resolveStartRoute(route: String?): MainRoute {
@@ -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,
@@ -48,9 +48,8 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.article.RssSortActivity
import io.legado.app.ui.rss.navigation.RssMainNavContract
import io.legado.app.ui.rss.favorites.RssFavoritesActivity
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.rss.source.manage.RssSourceActivity
import io.legado.app.ui.rss.subscription.RuleSubActivity
@@ -73,7 +72,8 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssScreen(
viewModel: RssViewModel = koinViewModel()
viewModel: RssViewModel = koinViewModel(),
onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
@@ -83,18 +83,19 @@ fun RssScreen(
if (rssSource.singleUrl) {
viewModel.getSingleUrl(rssSource) { url ->
if (url.startsWith("http", true)) {
context.startActivity<ReadRssActivity> {
putExtra("title", rssSource.sourceName)
putExtra("origin", url)
}
context.startActivity(
RssMainNavContract.createRssReadIntent(
context = context,
title = rssSource.sourceName,
origin = url
)
)
} else {
context.openUrl(url)
}
}
} else {
context.startActivity<RssSortActivity> {
putExtra("url", rssSource.sourceUrl)
}
onOpenSort(rssSource.sourceUrl, null, null)
}
}
@@ -1,16 +0,0 @@
package io.legado.app.ui.rss.article
import android.content.Context
import androidx.viewbinding.ViewBinding
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.RssArticle
abstract class BaseRssArticlesAdapter<VB : ViewBinding>(context: Context, val callBack: CallBack) :
RecyclerAdapter<RssArticle, VB>(context) {
interface CallBack {
val isGridLayout: Boolean
fun readRss(rssArticle: RssArticle)
}
}
@@ -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
@@ -1,88 +0,0 @@
package io.legado.app.ui.rss.article
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.RssReadRecord
import io.legado.app.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.ItemRssReadRecordBinding
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.utils.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding
class ReadRecordDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModels<RssSortViewModel>()
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy {
ReadRecordAdapter(requireContext())
}
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.read_record)
toolBar.inflateMenu(R.menu.rss_read_record)
toolBar.setOnMenuItemClickListener(this@ReadRecordDialog)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
}
adapter.setItems(viewModel.getRecords())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_clear -> {
alert(R.string.draw) {
val countRead = viewModel.countRecords()
setMessage(getString(R.string.sure_del) + "\n" + countRead + " " + getString(R.string.read_record))
noButton()
yesButton {
viewModel.deleteAllRecord()
adapter.clearItems()
}
}
}
}
return true
}
inner class ReadRecordAdapter(context: Context) :
RecyclerAdapter<RssReadRecord, ItemRssReadRecordBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemRssReadRecordBinding {
return ItemRssReadRecordBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssReadRecordBinding,
item: RssReadRecord,
payloads: MutableList<Any>
) {
binding.textTitle.text = item.title
binding.textRecord.text = item.record
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssReadRecordBinding) {
}
}
}
@@ -1,91 +0,0 @@
package io.legado.app.ui.rss.article
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.RssArticle
import io.legado.app.databinding.ItemRssArticleBinding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.utils.gone
import io.legado.app.utils.themeColor
import io.legado.app.utils.visible
class RssArticlesAdapter(context: Context, callBack: CallBack) :
BaseRssArticlesAdapter<ItemRssArticleBinding>(context, callBack) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding {
return ItemRssArticleBinding.inflate(inflater, parent, false)
}
@SuppressLint("CheckResult")
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticleBinding,
item: RssArticle,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image).apply(options).apply {
if (callBack.isGridLayout) {
placeholder(R.drawable.image_rss_article)
} else {
addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
}
}.into(imageView)
}
if (item.read) {
tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant))
} else {
tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -1,90 +0,0 @@
package io.legado.app.ui.rss.article
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.RssArticle
import io.legado.app.databinding.ItemRssArticle1Binding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.utils.gone
import io.legado.app.utils.visible
class RssArticlesAdapter1(context: Context, callBack: CallBack) :
BaseRssArticlesAdapter<ItemRssArticle1Binding>(context, callBack) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticle1Binding {
return ItemRssArticle1Binding.inflate(inflater, parent, false)
}
@SuppressLint("CheckResult")
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticle1Binding,
item: RssArticle,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image).apply(options).apply {
if (callBack.isGridLayout) {
placeholder(R.drawable.image_rss_article)
} else {
addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
}
}.into(imageView)
}
// if (item.read) {
// tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary))
// } else {
// tvTitle.setTextColor(context.getCompatColor(R.color.primaryText))
// }
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle1Binding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -1,91 +0,0 @@
package io.legado.app.ui.rss.article
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.RssArticle
import io.legado.app.databinding.ItemRssArticle2Binding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.utils.gone
import io.legado.app.utils.themeColor
import io.legado.app.utils.visible
class RssArticlesAdapter2(context: Context, callBack: CallBack) :
BaseRssArticlesAdapter<ItemRssArticle2Binding>(context, callBack) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticle2Binding {
return ItemRssArticle2Binding.inflate(inflater, parent, false)
}
@SuppressLint("CheckResult")
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticle2Binding,
item: RssArticle,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image).apply(options).apply {
if (callBack.isGridLayout) {
placeholder(R.drawable.image_rss_article)
} else {
addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
}
}.into(imageView)
}
if (item.read) {
tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant))
} else {
tvTitle.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle2Binding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -1,91 +0,0 @@
package io.legado.app.ui.rss.article
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.RssArticle
import io.legado.app.databinding.ItemRssArticle3Binding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.utils.gone
import io.legado.app.utils.themeColor
import io.legado.app.utils.visible
class RssArticlesAdapter3(context: Context, callBack: CallBack) :
BaseRssArticlesAdapter<ItemRssArticle3Binding>(context, callBack) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticle3Binding {
return ItemRssArticle3Binding.inflate(inflater, parent, false)
}
@SuppressLint("CheckResult")
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticle3Binding,
item: RssArticle,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
val imageUrl = item.image
if (imageUrl.isNullOrBlank() && !callBack.isGridLayout) {
imageView.gone()
} else {
imageView.visible()
imageView.adjustViewBounds = true
val options = RequestOptions()
.set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, imageUrl)
.apply(options)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
return false
}
})
.into(imageView)
}
tvTitle.setTextColor(
if (item.read)
context.themeColor(com.google.android.material.R.attr.colorOnSurfaceVariant)
else
context.themeColor(com.google.android.material.R.attr.colorOnSurface)
)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle3Binding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -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
)
}
@@ -1,40 +1,21 @@
package io.legado.app.ui.rss.article
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import io.legado.app.R
import io.legado.app.base.VMBaseFragment
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import io.legado.app.data.entities.RssArticle
import io.legado.app.databinding.FragmentRssArticlesBinding
import io.legado.app.databinding.ViewLoadMoreBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.widget.recycler.LoadMoreView
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.ui.rss.navigation.RssMainNavContract
import io.legado.app.ui.theme.AppTheme
import io.legado.app.utils.startActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.activityViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.fragment_rss_articles),
BaseRssArticlesAdapter.CallBack {
class RssArticlesFragment() : Fragment() {
constructor(sortName: String, sortUrl: String) : this() {
arguments = Bundle().apply {
@@ -43,118 +24,49 @@ class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.frag
}
}
private val binding by viewBinding(FragmentRssArticlesBinding::bind)
private val activityViewModel by activityViewModels<RssSortViewModel>()
override val viewModel by viewModels<RssArticlesViewModel>()
private val adapter: BaseRssArticlesAdapter<*> by lazy {
when (activityViewModel.rssSource?.articleStyle) {
1 -> RssArticlesAdapter1(requireContext(), this@RssArticlesFragment)
2 -> RssArticlesAdapter2(requireContext(), this@RssArticlesFragment)
3 -> RssArticlesAdapter3(requireContext(), this@RssArticlesFragment)
else -> RssArticlesAdapter(requireContext(), this@RssArticlesFragment)
}
}
private val loadMoreView: LoadMoreView by lazy {
LoadMoreView(requireContext())
}
private var articlesFlowJob: Job? = null
override val isGridLayout: Boolean
get() = activityViewModel.isGridLayout
private val activityViewModel: RssSortViewModel by activityViewModel()
private val viewModel: RssArticlesViewModel by viewModel()
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
viewModel.init(arguments)
initView()
initData()
}
private fun initView() = binding.run {
// refreshLayout.setColorSchemeColors(accentColor)
// recyclerView.setEdgeEffectColor(primaryColor)
recyclerView.applyNavigationBarPadding()
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading) {
scrollToBottom(true)
}
}
val layoutManager = if (activityViewModel.isWaterLayout) {
recyclerView.itemAnimator = null
StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
} else if (activityViewModel.isGridLayout) {
GridLayoutManager(requireContext(), 2)
} else {
LinearLayoutManager(requireContext())
}
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
adapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
refreshLayout.setOnRefreshListener {
loadArticles()
}
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
scrollToBottom()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
Content()
}
}
})
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
refreshLayout.isRefreshing = true
loadArticles()
this@launch.cancel()
}
}
}
private fun initData() {
val rssUrl = activityViewModel.url ?: return
articlesFlowJob?.cancel()
articlesFlowJob = viewLifecycleOwner.lifecycleScope.launch {
appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).catch {
AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
@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<ReadRssActivity> {
putExtra("title", rssArticle.title)
putExtra("origin", rssArticle.origin)
putExtra("link", rssArticle.link)
}
requireContext().startActivity(
RssMainNavContract.createRssReadIntent(
context = requireContext(),
title = rssArticle.title,
origin = rssArticle.origin,
link = rssArticle.link
)
)
}
}
}
@@ -2,7 +2,6 @@ package io.legado.app.ui.rss.article
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
@@ -12,12 +11,15 @@ import io.legado.app.data.entities.RssSource
import io.legado.app.model.rss.Rss
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class RssArticlesViewModel(application: Application) : BaseViewModel(application) {
val loadFinallyLiveData = MutableLiveData<Boolean>()
val loadErrorLiveData = MutableLiveData<String>()
var isLoading = true
private val _loadState = MutableStateFlow(RssArticlesLoadState())
val loadState: StateFlow<RssArticlesLoadState> = _loadState.asStateFlow()
var order = System.currentTimeMillis()
private var nextPageUrl: String? = null
var sortName: String = ""
@@ -26,13 +28,29 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
fun init(bundle: Bundle?) {
bundle?.let {
sortName = it.getString("sortName") ?: ""
sortUrl = it.getString("sortUrl") ?: ""
init(
sortName = it.getString("sortName") ?: "",
sortUrl = it.getString("sortUrl") ?: ""
)
}
}
fun init(sortName: String, sortUrl: String) {
if (this.sortName == sortName && this.sortUrl == sortUrl) return
this.sortName = sortName
this.sortUrl = sortUrl
page = 1
nextPageUrl = null
_loadState.value = RssArticlesLoadState()
}
fun loadArticles(rssSource: RssSource) {
isLoading = true
_loadState.value = _loadState.value.copy(
isRefreshing = true,
isLoadingMore = false,
errorMessage = null,
hasMore = true
)
page = 1
order = System.currentTimeMillis()
Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) {
@@ -46,51 +64,91 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order)
}
val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty()
loadFinallyLiveData.postValue(hasMore)
isLoading = false
_loadState.value = _loadState.value.copy(
isRefreshing = false,
isLoadingMore = false,
hasMore = hasMore,
errorMessage = null
)
}.onError {
loadFinallyLiveData.postValue(false)
AppLog.put("rss获取内容失败", it)
loadErrorLiveData.postValue(it.stackTraceStr)
_loadState.value = _loadState.value.copy(
isRefreshing = false,
isLoadingMore = false,
hasMore = false,
errorMessage = it.stackTraceStr
)
}
}
fun loadMore(rssSource: RssSource) {
isLoading = true
page++
val currentState = _loadState.value
if (currentState.isRefreshing || currentState.isLoadingMore || !currentState.hasMore) return
val pageUrl = nextPageUrl
if (pageUrl.isNullOrEmpty()) {
loadFinallyLiveData.postValue(false)
_loadState.value = currentState.copy(hasMore = false)
return
}
_loadState.value = currentState.copy(
isLoadingMore = true,
errorMessage = null
)
page++
Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
nextPageUrl = it.second
loadMoreSuccess(it.first)
isLoading = false
}.onError {
loadFinallyLiveData.postValue(false)
AppLog.put("rss获取内容失败", it)
loadErrorLiveData.postValue(it.stackTraceStr)
_loadState.value = _loadState.value.copy(
isLoadingMore = false,
errorMessage = it.stackTraceStr
)
}
}
private fun loadMoreSuccess(articles: MutableList<RssArticle>) {
if (articles.isEmpty()) {
loadFinallyLiveData.postValue(false)
_loadState.value = _loadState.value.copy(
isLoadingMore = false,
hasMore = false
)
return
}
val firstArticle = articles.first()
val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
val lastArticle = articles.last()
val dbLastArticle = appDb.rssArticleDao.getByLink(lastArticle.origin, lastArticle.link)
if (dbFirstArticle != null && dbLastArticle != null) {
loadFinallyLiveData.postValue(false)
} else {
articles.forEach {
it.order = order--
}
appDb.rssArticleDao.append(*articles.toTypedArray())
}
}
}
val shouldStop = dbFirstArticle != null && dbLastArticle != null
if (shouldStop) {
_loadState.value = _loadState.value.copy(
isLoadingMore = false,
hasMore = false
)
return
}
articles.forEach {
it.order = order--
}
appDb.rssArticleDao.append(*articles.toTypedArray())
_loadState.value = _loadState.value.copy(
isLoadingMore = false,
hasMore = !nextPageUrl.isNullOrBlank()
)
}
}
data class RssArticlesLoadState(
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
val errorMessage: String? = null
) {
val canLoadMore: Boolean
get() = hasMore && !isRefreshing && !isLoadingMore
}
@@ -1,265 +0,0 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.rss.article
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.databinding.ActivityRssArtivlesBinding
import io.legado.app.help.source.sortUrls
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.read.ReadRssActivity.RedirectPolicy
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.gone
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.themeColor
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RssSortActivity : VMBaseActivity<ActivityRssArtivlesBinding, RssSortViewModel>(),
VariableDialog.Callback {
override val binding by viewBinding(ActivityRssArtivlesBinding::inflate)
override val viewModel by viewModels<RssSortViewModel>()
private var adapter: TabFragmentPageAdapter? = null
private val sortList = mutableListOf<Pair<String, String>>()
private val editSourceResult = registerForActivityResult(
StartActivityContract(RssSourceEditActivity::class.java)
) {
if (it.resultCode == RESULT_OK) {
viewModel.initData(intent) {
upFragments()
}
}
}
var redirectPolicy: RedirectPolicy = RedirectPolicy.ALLOW_ALL
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.topBar)
viewModel.titleLiveData.observe(this) {
binding.ctlBar.title = it
}
viewModel.initData(intent) {
upFragments()
redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
}
binding.titleBar.addOnOffsetChangedListener({ appBarLayout, verticalOffset ->
if (-verticalOffset >= appBarLayout.totalScrollRange) {
val drawable = binding.tabRightFade.background as? GradientDrawable
drawable?.colors = intArrayOf(Color.TRANSPARENT,
themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
} else {
val drawable = binding.tabRightFade.background as? GradientDrawable
drawable?.colors = intArrayOf(Color.TRANSPARENT,
themeColor(com.google.android.material.R.attr.colorSurface))
}
})
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_articles, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_login)?.isVisible =
!viewModel.rssSource?.loginUrl.isNullOrBlank()
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_login -> startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
R.id.menu_refresh_sort -> viewModel.clearSortCache { upFragments() }
R.id.menu_set_source_variable -> setSourceVariable()
R.id.menu_edit_source -> viewModel.rssSource?.sourceUrl?.let {
editSourceResult.launch {
putExtra("sourceUrl", it)
}
}
R.id.menu_redirect_policy ->
showRedirectPolicySubMenu()
R.id.menu_clear -> {
viewModel.url?.let {
viewModel.clearArticles()
}
}
R.id.menu_switch_layout -> {
viewModel.switchLayout()
upFragments()
}
R.id.menu_read_record -> {
showDialogFragment<ReadRecordDialog>()
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun showRedirectPolicySubMenu() {
val popup = PopupMenu(this, binding.titleBar, Gravity.END)
popup.menuInflater.inflate(R.menu.menu_redirect_policy_submenu, popup.menu)
setCurrentRedirectPolicyChecked(popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
handleRedirectPolicySelection(menuItem)
true
}
popup.show()
}
private fun setCurrentRedirectPolicyChecked(menu: Menu) {
val currentPolicy = redirectPolicy
val menuItemId = when (currentPolicy) {
RedirectPolicy.ALLOW_ALL -> R.id.menu_redirect_allow_all
RedirectPolicy.ASK_ALWAYS -> R.id.menu_redirect_ask_always
RedirectPolicy.ASK_CROSS_ORIGIN -> R.id.menu_redirect_ask_cross_origin
RedirectPolicy.BLOCK_CROSS_ORIGIN -> R.id.menu_redirect_block_cross_origin
RedirectPolicy.BLOCK_ALL -> R.id.menu_redirect_block_all
RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> R.id.menu_redirect_ask_same_domain_block_cross
}
menu.findItem(menuItemId)?.isChecked = true
}
private fun handleRedirectPolicySelection(menuItem: MenuItem) {
val selectedPolicy = when (menuItem.itemId) {
R.id.menu_redirect_allow_all -> RedirectPolicy.ALLOW_ALL
R.id.menu_redirect_ask_always -> RedirectPolicy.ASK_ALWAYS
R.id.menu_redirect_ask_cross_origin -> RedirectPolicy.ASK_CROSS_ORIGIN
R.id.menu_redirect_block_cross_origin -> RedirectPolicy.BLOCK_CROSS_ORIGIN
R.id.menu_redirect_block_all -> RedirectPolicy.BLOCK_ALL
R.id.menu_redirect_ask_same_domain_block_cross -> RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS
else -> RedirectPolicy.ALLOW_ALL
}
updateRedirectPolicy(selectedPolicy)
toastOnUi("重定向策略已更新")
}
fun updateRedirectPolicy(policy: RedirectPolicy) {
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
}
private fun upFragments() {
lifecycleScope.launch {
val sorts = viewModel.rssSource?.sortUrls().orEmpty()
sortList.clear()
sortList.addAll(sorts)
if (sortList.size <= 1) {
binding.llTab.gone()
} else {
binding.llTab.visible()
}
adapter = TabFragmentPageAdapter(this@RssSortActivity, sortList)
binding.viewPager.adapter = adapter
// 重新绑定 TabLayout
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = sortList[position].first
}.attach()
}
binding.btnExpandTabs.setOnClickListener {
val popup = PopupMenu(this, it)
sortList.forEachIndexed { index, pair ->
popup.menu.add(0, index, index, pair.first)
}
popup.setOnMenuItemClickListener { item ->
binding.viewPager.setCurrentItem(item.itemId, true)
true
}
popup.setOnDismissListener {
binding.btnExpandTabs.isChecked = false
}
popup.show()
}
}
private fun setSourceVariable() {
lifecycleScope.launch {
val source = viewModel.rssSource
if (source == null) {
toastOnUi("源不存在")
return@launch
}
val comment =
source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
val variable = withContext(Dispatchers.IO) { source.getVariable() }
showDialogFragment(
VariableDialog(
getString(R.string.set_source_variable),
source.getKey(),
variable,
comment
)
)
}
}
override fun setVariable(key: String, variable: String?) {
viewModel.rssSource?.setVariable(variable)
}
private class TabFragmentPageAdapter(
activity: FragmentActivity,
private val items: List<Pair<String, String>>
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = items.size
override fun createFragment(position: Int): Fragment {
val sort = items[position]
return RssArticlesFragment(sort.first, sort.second)
}
}
companion object {
fun start(context: Context, sortUrl: String?, sourceUrl: String, key: String? = null) {
context.startActivity<RssSortActivity> {
putExtra("sortUrl", sortUrl)
putExtra("sourceUrl", sourceUrl)
putExtra("key", key)
}
}
}
}
@@ -0,0 +1,189 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.rss.article
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.appcompat.app.AppCompatActivity
import io.legado.app.R
import io.legado.app.data.entities.RssReadRecord
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.navigation.RssMainNavContract
import io.legado.app.ui.rss.read.RedirectPolicy
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
import androidx.compose.ui.res.stringResource
@Composable
fun RssSortRouteScreen(
sourceUrl: String?,
initialSortUrl: String?,
onBackClick: () -> Unit,
viewModel: RssSortViewModel = koinViewModel()
) {
val context = LocalContext.current
val activity = LocalActivity.current as? AppCompatActivity
val scope = rememberCoroutineScope()
var sortList by remember(sourceUrl) { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var articleStyle by remember(sourceUrl) { mutableIntStateOf(0) }
var redirectPolicy by remember(sourceUrl) { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
var screenTitle by remember(sourceUrl) { mutableStateOf("") }
var showReadRecordSheet by remember { mutableStateOf(false) }
var readRecords by remember { mutableStateOf<List<RssReadRecord>>(emptyList()) }
fun reloadSourceState() {
viewModel.initData(sourceUrl) {
scope.launch {
sortList = viewModel.loadSorts()
articleStyle = viewModel.currentArticleStyle()
screenTitle = viewModel.rssSource?.sourceName.orEmpty()
redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
}
}
}
val editSourceResult = rememberLauncherForActivityResult(
StartActivityContract(RssSourceEditActivity::class.java)
) {
if (it.resultCode == Activity.RESULT_OK) {
reloadSourceState()
}
}
androidx.compose.runtime.LaunchedEffect(sourceUrl) {
reloadSourceState()
}
RssSortScreen(
title = screenTitle.ifBlank { stringResource(R.string.rss) },
sortList = sortList,
preferredSortUrl = initialSortUrl,
hasLogin = !viewModel.rssSource?.loginUrl.isNullOrBlank(),
redirectPolicy = redirectPolicy,
showReadRecordSheet = showReadRecordSheet,
readRecords = readRecords,
onBackClick = onBackClick,
onLogin = {
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
},
onRefreshSort = {
viewModel.clearSortCache {
scope.launch { sortList = viewModel.loadSorts() }
}
},
onSetSourceVariable = {
scope.launch {
val source = viewModel.rssSource
if (source == null) {
context.toastOnUi("源不存在")
return@launch
}
val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
val variable = withContext(Dispatchers.IO) { source.getVariable() }
activity?.showDialogFragment(
VariableDialog(
context.getString(R.string.set_source_variable),
source.getKey(),
variable,
comment
)
)
}
},
onEditSource = {
viewModel.rssSource?.sourceUrl?.let { srcUrl ->
editSourceResult.launch { putExtra("sourceUrl", srcUrl) }
}
},
onSwitchLayout = {
viewModel.switchLayout()
articleStyle = viewModel.currentArticleStyle()
},
onReadRecord = {
scope.launch(Dispatchers.IO) {
val records = viewModel.getRecords()
withContext(Dispatchers.Main) {
readRecords = records
showReadRecordSheet = true
}
}
},
onDismissReadRecord = { showReadRecordSheet = false },
onClearReadRecord = {
viewModel.deleteAllRecord()
readRecords = emptyList()
},
onOpenReadRecord = { record ->
showReadRecordSheet = false
val openOrigin = record.origin.ifBlank {
viewModel.rssSource?.sourceUrl ?: sourceUrl.orEmpty()
}
if (openOrigin.isBlank()) {
context.toastOnUi(context.getString(R.string.error))
} else {
context.startActivity(
RssMainNavContract.createRssReadIntent(
context = context,
title = record.title,
origin = openOrigin,
openUrl = record.record
)
)
}
},
onClearArticles = { viewModel.clearArticles() },
onRedirectPolicyChanged = { policy ->
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
context.toastOnUi("重定向策略已更新")
},
pagerContent = { _, sort ->
val pageViewModel: RssArticlesViewModel = koinViewModel(
key = "rss_${viewModel.url}_${sort.first}_${sort.second}"
)
RssArticlesPage(
sortName = sort.first,
sortUrl = sort.second,
articleStyle = articleStyle,
rssUrl = viewModel.url,
rssSource = viewModel.rssSource,
viewModel = pageViewModel,
onRead = { article ->
viewModel.read(article)
context.startActivity(
RssMainNavContract.createRssReadIntent(
context = context,
title = article.title,
origin = article.origin,
link = article.link
)
)
}
)
}
)
}
@@ -0,0 +1,364 @@
package io.legado.app.ui.rss.article
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.Dataset
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Style
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.RssReadRecord
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.rss.read.RedirectPolicy
import io.legado.app.ui.rss.read.title
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssSortScreen(
title: String,
sortList: List<Pair<String, String>>,
preferredSortUrl: String?,
hasLogin: Boolean,
redirectPolicy: RedirectPolicy,
showReadRecordSheet: Boolean,
readRecords: List<RssReadRecord>,
onBackClick: () -> Unit,
onLogin: () -> Unit,
onRefreshSort: () -> Unit,
onSetSourceVariable: () -> Unit,
onEditSource: () -> Unit,
onSwitchLayout: () -> Unit,
onReadRecord: () -> Unit,
onDismissReadRecord: () -> Unit,
onClearReadRecord: () -> Unit,
onOpenReadRecord: (RssReadRecord) -> Unit,
onClearArticles: () -> Unit,
onRedirectPolicyChanged: (RedirectPolicy) -> Unit,
pagerContent: @Composable (index: Int, item: Pair<String, String>) -> Unit
) {
val scope = rememberCoroutineScope()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val initialPage = remember(sortList, preferredSortUrl) {
val index = sortList.indexOfFirst { it.second == preferredSortUrl }
if (index >= 0) index else 0
}
val pagerState = rememberPagerState(initialPage = initialPage) {
sortList.size.coerceAtLeast(1)
}
val selectedTabIndex = pagerState.currentPage.coerceIn(0, (sortList.size - 1).coerceAtLeast(0))
val tabTitles = sortList.map { it.first }
var showMainMenu by remember { mutableStateOf(false) }
var showRedirectMenu by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) }
BackHandler(enabled = showMainMenu || showRedirectMenu || showGroupMenu) {
showMainMenu = false
showRedirectMenu = false
showGroupMenu = false
}
LaunchedEffect(sortList.size) {
if (sortList.isEmpty()) return@LaunchedEffect
val safeIndex = pagerState.currentPage.coerceIn(0, sortList.lastIndex)
if (safeIndex != pagerState.currentPage) {
pagerState.scrollToPage(safeIndex)
}
}
Column(modifier = Modifier.fillMaxSize()) {
GlassMediumFlexibleTopAppBar(
title = title,
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick, imageVector = Icons.AutoMirrored.Filled.ArrowBack)
},
actions = {
TopBarActionButton(
onClick = { showMainMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu"
)
RoundDropdownMenu(
expanded = showMainMenu,
onDismissRequest = { showMainMenu = false }
) { dismiss ->
if (hasLogin) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
onClick = {
dismiss()
onLogin()
}
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.refresh_sort),
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
onClick = {
dismiss()
onRefreshSort()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.set_source_variable),
leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
onClick = {
dismiss()
onSetSourceVariable()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.edit_source),
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
onClick = {
dismiss()
onEditSource()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.switchLayout),
leadingIcon = { MenuItemIcon(Icons.Default.Style) },
onClick = {
dismiss()
onSwitchLayout()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.read_record),
leadingIcon = { MenuItemIcon(Icons.Default.History) },
onClick = {
dismiss()
onReadRecord()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.clear),
leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
onClick = {
dismiss()
onClearArticles()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
}
RoundDropdownMenu(
expanded = showRedirectMenu,
onDismissRequest = { showRedirectMenu = false },
modifier = Modifier.width(280.dp)
) { dismiss ->
RedirectPolicy.entries.forEach { policy ->
RoundDropdownMenuItem(
text = policy.title(),
isSelected = policy == redirectPolicy,
onClick = {
dismiss()
showMainMenu = false
onRedirectPolicyChanged(policy)
}
)
}
}
},
bottomContent = {
if (sortList.size > 1) {
Row(
modifier = Modifier
.fillMaxWidth()
.adaptiveHorizontalPaddingTab(),
verticalAlignment = Alignment.CenterVertically
) {
AppTabRow(
tabTitles = tabTitles,
selectedTabIndex = selectedTabIndex,
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
},
modifier = Modifier.weight(1f)
)
if (BookshelfConfig.shouldShowExpandButton) {
Box {
SmallOutlinedIconToggleButton(
checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it },
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
sortList.forEachIndexed { index, group ->
RoundDropdownMenuItem(
text = group.first,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
dismiss()
},
trailingIcon = {
if (selectedTabIndex == index) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
}
}
}
}
)
if (sortList.isEmpty()) {
EmptyMessage(
message = stringResource(R.string.empty),
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
)
} else {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(bottom = 0.dp),
modifier = Modifier.fillMaxSize()
) { page ->
val item = sortList.getOrNull(page) ?: return@HorizontalPager
pagerContent(page, item)
}
}
}
RssReadRecordSheet(
show = showReadRecordSheet,
records = readRecords,
onDismissRequest = onDismissReadRecord,
onClear = onClearReadRecord,
onOpen = onOpenReadRecord
)
}
@Composable
private fun RssReadRecordSheet(
show: Boolean,
records: List<RssReadRecord>,
onDismissRequest: () -> Unit,
onClear: () -> Unit,
onOpen: (RssReadRecord) -> Unit
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.read_record),
endAction = {
SmallIconButton(
onClick = onClear,
imageVector = Icons.Default.CleaningServices,
contentDescription = stringResource(R.string.clear)
)
}
) {
if (records.isEmpty()) {
EmptyMessage(
message = stringResource(R.string.empty),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
)
} else {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(records, key = { it.record }) { record ->
GlassCard(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
onClick = { onOpen(record) },
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
AppText(
text = record.title.orEmpty(),
style = LegadoTheme.typography.titleSmall,
color = LegadoTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
AppText(
text = record.record,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
}
}
@@ -9,10 +9,10 @@ import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssReadRecord
import io.legado.app.data.entities.RssSource
import io.legado.app.help.source.removeSortCache
import io.legado.app.help.source.sortUrls
import io.legado.app.utils.toastOnUi
import splitties.init.appCtx
class RssSortViewModel(application: Application) : BaseViewModel(application) {
var url: String? = null
var rssSource: RssSource? = null
@@ -22,14 +22,18 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
val isWaterLayout get() = rssSource?.articleStyle == 3
fun initData(intent: Intent, finally: () -> Unit) {
initData(intent.getStringExtra("url") ?: intent.getStringExtra("sourceUrl"), finally)
}
fun initData(sourceUrl: String?, finally: () -> Unit) {
execute {
url = intent.getStringExtra("url")
url?.let { url ->
rssSource = appDb.rssSourceDao.getByKey(url)
url = sourceUrl
url?.let { sourceUrl ->
rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
rssSource?.let {
titleLiveData.postValue(it.sourceName)
} ?: let {
rssSource = RssSource(sourceUrl = url)
rssSource = RssSource(sourceUrl = sourceUrl)
}
}
}.onFinally {
@@ -37,6 +41,12 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
}
}
suspend fun loadSorts(): List<Pair<String, String>> {
return rssSource?.sortUrls().orEmpty()
}
fun currentArticleStyle(): Int = rssSource?.articleStyle ?: 0
fun switchLayout() {
rssSource?.let {
if (it.articleStyle < 3) {
@@ -67,8 +77,6 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
appDb.rssArticleDao.delete(it)
}
order = System.currentTimeMillis()
}.onSuccess {
}
}
@@ -84,7 +92,7 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
return appDb.rssReadRecordDao.getRecords()
}
fun countRecords() : Int {
fun countRecords(): Int {
return appDb.rssReadRecordDao.countRecords
}
@@ -102,5 +110,4 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
appCtx.toastOnUi("保存失败: ${it.localizedMessage}")
}
}
}
}
@@ -1,91 +0,0 @@
package io.legado.app.ui.rss.favorites
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.RssStar
import io.legado.app.databinding.ItemRssArticleBinding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.utils.gone
import io.legado.app.utils.visible
class RssFavoritesAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RssStar, ItemRssArticleBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding {
return ItemRssArticleBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticleBinding,
item: RssStar,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank()) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image)
.apply(options)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
.into(imageView)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
holder.itemView.setOnLongClickListener {
getItem(holder.layoutPosition)?.let {
callBack.delStar(it)
}
true
}
}
interface CallBack {
fun readRss(rssStar: RssStar)
fun delStar(rssStar: RssStar)
}
}
@@ -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()
}
}
@@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.legado.app.R
import io.legado.app.data.entities.RssStar
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.rss.navigation.RssMainNavContract
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SourceIcon
@@ -224,11 +224,14 @@ fun RssFavoritesScreen(
} else null,
trailingAction = {
val openAction = {
context.startActivity<ReadRssActivity> {
putExtra("title", rssStar.title)
putExtra("origin", rssStar.origin)
putExtra("link", rssStar.link)
}
context.startActivity(
RssMainNavContract.createRssReadIntent(
context = context,
title = rssStar.title,
origin = rssStar.origin,
link = rssStar.link
)
)
}
SmallIconButton(
onClick = openAction,
@@ -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
}
}
}
@@ -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
@@ -1,627 +0,0 @@
package io.legado.app.ui.rss.read
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.SystemClock
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.size
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.RssSource
import io.legado.app.databinding.ActivityRssReadBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.CookieManager
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.Download
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.favorites.RssFavoritesDialog
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.isTrue
import io.legado.app.utils.keepScreenOn
import io.legado.app.utils.longSnackbar
import io.legado.app.utils.openUrl
import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
import io.legado.app.utils.share
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.textArray
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.toggleSystemBar
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
import splitties.views.bottomPadding
import java.net.URLDecoder
/**
* rss阅读界面
*/
class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>(),
RssFavoritesDialog.Callback {
enum class RedirectPolicy {
ALLOW_ALL,
ASK_ALWAYS,
ASK_CROSS_ORIGIN,
BLOCK_CROSS_ORIGIN,
BLOCK_ALL,
ASK_SAME_DOMAIN_BLOCK_CROSS;
companion object {
fun fromString(value: String?): RedirectPolicy {
return entries.find { it.name.equals(value, ignoreCase = true) } ?: ALLOW_ALL
}
}
}
var redirectPolicy: RedirectPolicy = RedirectPolicy.ALLOW_ALL
override val binding by viewBinding(ActivityRssReadBinding::inflate)
override val viewModel by viewModels<ReadRssViewModel>()
private var starMenuItem: MenuItem? = null
private var ttsMenuItem: MenuItem? = null
private var redirectPolicyMenu: MenuItem? = null
private var isFullScreen = false
private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
private val rssJsExtensions by lazy { RssJsExtensions(this, viewModel.rssSource) }
fun getSource(): RssSource? {
return viewModel.rssSource
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.upStarMenuData.observe(this) { upStarMenu() }
viewModel.upTtsMenuData.observe(this) { upTtsMenu(it) }
binding.titleBar.title = intent.getStringExtra("title")
initView()
initWebView()
initLiveData()
viewModel.initData(intent)
onBackPressedDispatcher.addCallback(this) {
if (isFullScreen) {
toggleFullScreen()
} else {
if (binding.customWebView.size > 0) {
customWebViewCallback?.onCustomViewHidden()
return@addCallback
} else if (binding.webView.canGoBack()
&& binding.webView.copyBackForwardList().size > 1
) {
binding.webView.goBack()
return@addCallback
}
finish()
}
}
}
@Suppress("DEPRECATION")
@SuppressLint("SwitchIntDef")
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
when (newConfig.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
Configuration.ORIENTATION_PORTRAIT -> {
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
}
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_read, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
starMenuItem = menu.findItem(R.id.menu_rss_star)
ttsMenuItem = menu.findItem(R.id.menu_aloud)
redirectPolicyMenu = menu.findItem(R.id.menu_redirect_policy)
redirectPolicyMenu?.setOnMenuItemClickListener {
showRedirectPolicySubMenu()
true
}
upStarMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank()
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_full_screen -> toggleFullScreen()
R.id.menu_rss_refresh -> viewModel.refresh {
binding.webView.reload()
}
R.id.menu_rss_star -> {
viewModel.addFavorite()
viewModel.rssArticle?.let {
showDialogFragment(RssFavoritesDialog(it))
}
}
R.id.menu_share_it -> {
binding.webView.url?.let {
share(it)
} ?: viewModel.rssArticle?.let {
share(it.link)
} ?: toastOnUi(R.string.null_url)
}
R.id.menu_aloud -> readAloud()
R.id.menu_login -> startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
R.id.menu_browser_open -> binding.webView.url?.let {
openUrl(it)
} ?: toastOnUi("url null")
}
return super.onCompatOptionsItemSelected(item)
}
override fun updateFavorite(title: String?, group: String?) {
viewModel.rssArticle?.let {
if (title != null) {
it.title = title
}
if (group != null) {
it.group = group
}
}
viewModel.updateFavorite()
}
override fun deleteFavorite() {
viewModel.delFavorite()
}
@JavascriptInterface
fun isNightTheme(): Boolean {
return AppConfig.isNightTheme
}
private fun showRedirectPolicySubMenu() {
val popup = PopupMenu(this, binding.titleBar, Gravity.END)
popup.menuInflater.inflate(R.menu.menu_redirect_policy_submenu, popup.menu)
setCurrentRedirectPolicyChecked(popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
handleRedirectPolicySelection(menuItem)
true
}
popup.show()
}
private fun setCurrentRedirectPolicyChecked(menu: Menu) {
val currentPolicy = redirectPolicy
val menuItemId = when (currentPolicy) {
RedirectPolicy.ALLOW_ALL -> R.id.menu_redirect_allow_all
RedirectPolicy.ASK_ALWAYS -> R.id.menu_redirect_ask_always
RedirectPolicy.ASK_CROSS_ORIGIN -> R.id.menu_redirect_ask_cross_origin
RedirectPolicy.BLOCK_CROSS_ORIGIN -> R.id.menu_redirect_block_cross_origin
RedirectPolicy.BLOCK_ALL -> R.id.menu_redirect_block_all
RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> R.id.menu_redirect_ask_same_domain_block_cross
}
menu.findItem(menuItemId)?.isChecked = true
}
private fun handleRedirectPolicySelection(menuItem: MenuItem) {
val selectedPolicy = when (menuItem.itemId) {
R.id.menu_redirect_allow_all -> RedirectPolicy.ALLOW_ALL
R.id.menu_redirect_ask_always -> RedirectPolicy.ASK_ALWAYS
R.id.menu_redirect_ask_cross_origin -> RedirectPolicy.ASK_CROSS_ORIGIN
R.id.menu_redirect_block_cross_origin -> RedirectPolicy.BLOCK_CROSS_ORIGIN
R.id.menu_redirect_block_all -> RedirectPolicy.BLOCK_ALL
R.id.menu_redirect_ask_same_domain_block_cross -> RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS
else -> RedirectPolicy.ALLOW_ALL
}
updateRedirectPolicy(selectedPolicy)
toastOnUi("重定向策略已更新")
}
private fun toggleFullScreen() {
isFullScreen = !isFullScreen
toggleSystemBar(!isFullScreen)
if (isFullScreen) {
supportActionBar?.hide()
toastOnUi("可通过系统手势退出全屏。")
} else {
supportActionBar?.show()
}
}
private fun initView() {
binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val insets = windowInsets.getInsets(typeMask)
view.bottomPadding = insets.bottom
windowInsets
}
}
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
private fun initWebView() {
//binding.progressBar.fontColor = accentColor
binding.webView.webChromeClient = CustomWebChromeClient()
binding.webView.webViewClient = CustomWebViewClient()
binding.webView.settings.apply {
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
allowContentAccess = true
builtInZoomControls = true
displayZoomControls = false
setDarkeningAllowed(AppConfig.isNightTheme)
}
binding.webView.addJavascriptInterface(this, "thisActivity")
binding.webView.setOnLongClickListener {
val hitTestResult = binding.webView.hitTestResult
if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
) {
hitTestResult.extra?.let { webPic ->
selector(
arrayListOf(
SelectItem(getString(R.string.action_save), "save"),
)
) { _, charSequence, _ ->
when (charSequence.value) {
"save" -> saveImage(webPic)
}
}
return@setOnLongClickListener true
}
}
return@setOnLongClickListener false
}
binding.webView.setDownloadListener { url, _, contentDisposition, _, _ ->
var fileName = URLUtil.guessFileName(url, contentDisposition, null)
fileName = URLDecoder.decode(fileName, "UTF-8")
binding.llView.longSnackbar(fileName, getString(R.string.action_download)) {
Download.start(this, url, fileName)
}
}
}
private fun saveImage(webPic: String) {
viewModel.saveImage(webPic)
}
@SuppressLint("SetJavaScriptEnabled")
private fun initLiveData() {
viewModel.contentLiveData.observe(this) { content ->
viewModel.rssArticle?.let {
if (viewModel.rssSource != null) {
redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
}
upJavaScriptEnable()
val url = NetworkUtils.getAbsoluteURL(it.origin, it.link)
val html = viewModel.clHtml(content)
binding.webView.settings.userAgentString =
viewModel.headerMap[AppConst.UA_NAME] ?: AppConfig.userAgent
if (viewModel.rssSource?.loadWithBaseUrl == true) {
binding.webView
.loadDataWithBaseURL(url, html, "text/html", "utf-8", url)//不想用baseUrl进else
} else {
binding.webView
.loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url)
}
}
}
viewModel.urlLiveData.observe(this) {
upJavaScriptEnable()
CookieManager.applyToWebView(it.url)
binding.webView.settings.userAgentString = it.getUserAgent()
binding.webView.loadUrl(it.url, it.headerMap)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun upJavaScriptEnable() {
if (viewModel.rssSource?.enableJs == true) {
binding.webView.settings.javaScriptEnabled = true
}
}
fun updateRedirectPolicy(policy: RedirectPolicy) {
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
}
private fun upStarMenu() {
starMenuItem?.isVisible = viewModel.rssArticle != null
if (viewModel.rssStar != null) {
starMenuItem?.setIcon(R.drawable.ic_star)
starMenuItem?.setTitle(R.string.in_favorites)
} else {
starMenuItem?.setIcon(R.drawable.ic_star_border)
starMenuItem?.setTitle(R.string.out_favorites)
}
//starMenuItem?.icon?.setTintMutate(primaryTextColor)
}
private fun upTtsMenu(isPlaying: Boolean) {
lifecycleScope.launch {
if (isPlaying) {
ttsMenuItem?.setIcon(R.drawable.ic_stop_black_24dp)
ttsMenuItem?.setTitle(R.string.aloud_stop)
} else {
ttsMenuItem?.setIcon(R.drawable.ic_volume_up)
ttsMenuItem?.setTitle(R.string.read_aloud)
}
//ttsMenuItem?.icon?.setTintMutate(primaryTextColor)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun readAloud() {
if (viewModel.tts?.isSpeaking == true) {
viewModel.tts?.stop()
upTtsMenu(false)
} else {
binding.webView.settings.javaScriptEnabled = true
binding.webView.evaluateJavascript("document.documentElement.outerHTML") {
val html = StringEscapeUtils.unescapeJson(it)
.replace("^\"|\"$".toRegex(), "")
viewModel.readAloud(
Jsoup.parse(html)
.textArray()
.joinToString("\n")
)
}
}
}
override fun onDestroy() {
super.onDestroy()
binding.webView.destroy()
}
inner class CustomWebChromeClient : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
binding.progressBar.setProgress(newProgress)
binding.progressBar.gone(newProgress == 100)
}
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
binding.llView.invisible()
binding.customWebView.addView(view)
customWebViewCallback = callback
keepScreenOn(true)
toggleSystemBar(false)
}
override fun onHideCustomView() {
binding.customWebView.removeAllViews()
binding.llView.visible()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
keepScreenOn(false)
toggleSystemBar(true)
}
}
inner class CustomWebViewClient : WebViewClient() {
private var lastUrl: String? = null
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val targetUri = request.url
if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
return shouldOverrideUrlLoading(targetUri)
}
val currentUrl = lastUrl ?: view.url
val targetUrl = request.url.toString()
lastUrl = targetUrl
if (!request.isForMainFrame) return false
if (handleRedirect(view, currentUrl, targetUrl)) {
return true
}
return shouldOverrideUrlLoading(request.url)
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
val targetUri = url.toUri()
if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
return shouldOverrideUrlLoading(targetUri)
}
val currentUrl = lastUrl ?: view.url
val targetUrl = url
lastUrl = targetUrl
if (handleRedirect(view, currentUrl, targetUrl)) {
return true
}
return shouldOverrideUrlLoading(url.toUri())
}
private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
val fromHost = fromUrl?.toUri()?.host
val toHost = toUrl.toUri().host
val crossOrigin = fromHost != null && toHost != null && fromHost != toHost
return when (redirectPolicy) {
RedirectPolicy.ALLOW_ALL -> false
RedirectPolicy.BLOCK_ALL -> {
toastOnUi("已阻止重定向")
true
}
RedirectPolicy.ASK_ALWAYS -> {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
}
RedirectPolicy.ASK_CROSS_ORIGIN -> {
if (crossOrigin) {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
} else {
false
}
}
RedirectPolicy.BLOCK_CROSS_ORIGIN -> {
if (crossOrigin) {
toastOnUi("已阻止跨域重定向")
true
} else {
false
}
}
RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> {
if (crossOrigin) {
toastOnUi("已阻止域外跳转")
true
} else {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
}
}
}
}
private fun askUser(fromUrl: String?, toUrl: String, onResult: (Boolean) -> Unit) {
AlertDialog.Builder(this@ReadRssActivity)
.setTitle("重定向请求")
.setMessage("是否允许页面跳转?\n\n来源:${fromUrl ?: "未知"}\n目标:$toUrl")
.setPositiveButton("允许") { _, _ -> onResult(true) }
.setNegativeButton("拒绝") { _, _ -> onResult(false) }
.setCancelable(true)
.show()
}
private fun shouldOverrideUrlLoading(url: Uri): Boolean {
val source = viewModel.rssSource
val js = source?.shouldOverrideUrlLoading
if (!js.isNullOrBlank()) {
val t = SystemClock.uptimeMillis()
val result = kotlin.runCatching {
runScriptWithContext(lifecycleScope.coroutineContext) {
source.evalJS(js) {
put("java", rssJsExtensions)
put("url", url.toString())
}.toString()
}
}.onFailure {
AppLog.put("${source.getTag()}: url跳转拦截js出错", it)
}.getOrNull()
if (SystemClock.uptimeMillis() - t > 30) {
AppLog.put("${source.getTag()}: url跳转拦截js执行耗时过长")
}
if (result.isTrue()) {
return true
}
}
when (url.scheme) {
"http", "https", "jsbridge" -> {
return false
}
"legado", "yuedu" -> {
startActivity<OnLineImportActivity> {
data = url
}
return true
}
else -> {
binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
openUrl(url)
}
return true
}
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
view.title?.let { title ->
if (title != url && title != view.url && title.isNotBlank() && url != "about:blank") {
binding.titleBar.title = title
} else {
binding.titleBar.title = intent.getStringExtra("title")
}
}
viewModel.rssSource?.injectJs?.let {
if (it.isNotBlank()) {
view.evaluateJavascript(it, null)
}
}
}
@SuppressLint("WebViewClientOnReceivedSslError")
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed()
}
}
companion object {
fun start(context: Context, title: String?, url: String, origin: String) {
context.startActivity<ReadRssActivity> {
putExtra("title", title ?: "")
putExtra("origin", origin)
putExtra("openUrl", url)
}
}
private val webCookieManager by lazy { android.webkit.CookieManager.getInstance() }
}
}
@@ -4,7 +4,6 @@ import android.app.Application
import android.content.Intent
import android.util.Base64
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel
@@ -21,59 +20,94 @@ import io.legado.app.model.rss.Rss
import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import splitties.init.appCtx
import kotlin.coroutines.coroutineContext
data class ReadRssArgs(
val title: String? = null,
val origin: String,
val link: String? = null,
val openUrl: String? = null
)
class ReadRssViewModel(application: Application) : BaseViewModel(application) {
var rssSource: RssSource? = null
var rssArticle: RssArticle? = null
var tts: TTS? = null
val contentLiveData = MutableLiveData<String>()
val urlLiveData = MutableLiveData<AnalyzeUrl>()
var rssStar: RssStar? = null
val upTtsMenuData = MutableLiveData<Boolean>()
val upStarMenuData = MutableLiveData<Boolean>()
var headerMap: Map<String, String> = emptyMap()
private val _contentState = MutableStateFlow<String?>(null)
val contentState: StateFlow<String?> = _contentState.asStateFlow()
private val _urlState = MutableStateFlow<AnalyzeUrl?>(null)
val urlState: StateFlow<AnalyzeUrl?> = _urlState.asStateFlow()
private val _isSpeakingState = MutableStateFlow(false)
val isSpeakingState: StateFlow<Boolean> = _isSpeakingState.asStateFlow()
private val _rssStarState = MutableStateFlow<RssStar?>(null)
val rssStarState: StateFlow<RssStar?> = _rssStarState.asStateFlow()
fun initData(intent: Intent) {
val origin = intent.getStringExtra("origin") ?: return
initData(
ReadRssArgs(
title = intent.getStringExtra("title"),
origin = origin,
link = intent.getStringExtra("link"),
openUrl = intent.getStringExtra("openUrl")
)
)
}
fun initData(args: ReadRssArgs) {
execute {
val origin = intent.getStringExtra("origin") ?: return@execute
val link = intent.getStringExtra("link")
rssSource = appDb.rssSourceDao.getByKey(origin)
rssSource = appDb.rssSourceDao.getByKey(args.origin)
headerMap = runScriptWithContext {
rssSource?.getHeaderMap() ?: emptyMap()
}
if (link != null) {
rssStar = appDb.rssStarDao.get(origin, link)
rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.getByLink(origin, link)
val rssArticle = rssArticle ?: return@execute
if (!rssArticle.description.isNullOrBlank()) {
contentLiveData.postValue(rssArticle.description!!)
val link = args.link
if (!link.isNullOrBlank()) {
_rssStarState.value = appDb.rssStarDao.get(args.origin, link)
rssArticle = _rssStarState.value?.toRssArticle() ?: appDb.rssArticleDao.getByLink(args.origin, link)
val article = rssArticle ?: return@execute
if (!article.description.isNullOrBlank()) {
_contentState.value = article.description!!
} else {
rssSource?.let {
val ruleContent = it.ruleContent
if (!ruleContent.isNullOrBlank()) {
loadContent(rssArticle, ruleContent)
loadContent(article, ruleContent)
} else {
loadUrl(rssArticle.link, rssArticle.origin)
loadUrl(article.link, article.origin)
}
} ?: loadUrl(rssArticle.link, rssArticle.origin)
}
} 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()
}
}
}
@@ -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 -> "拦截所有"
}
}
@@ -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
)
)
}
}
@@ -0,0 +1,400 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.rss.read
import android.annotation.SuppressLint
import android.net.Uri
import android.net.http.SslError
import android.os.SystemClock
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.CookieManager
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.Download
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.isTrue
import io.legado.app.utils.keepScreenOn
import io.legado.app.utils.longSnackbar
import io.legado.app.utils.openUrl
import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.share
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
import java.net.URLDecoder
import org.koin.androidx.compose.koinViewModel
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssReadRouteScreen(
title: String?,
origin: String,
link: String?,
openUrl: String?,
onBackClick: () -> Unit,
viewModel: ReadRssViewModel = koinViewModel()
) {
val context = LocalContext.current
val activity = LocalActivity.current
val appCompatActivity = activity as? androidx.appcompat.app.AppCompatActivity
val scope = rememberCoroutineScope()
val defaultTopBarTitle = stringResource(R.string.rss)
var pageTitle by remember(title, defaultTopBarTitle) {
mutableStateOf(title?.takeIf { it.isNotBlank() } ?: defaultTopBarTitle)
}
var webProgress by remember { mutableIntStateOf(100) }
var showMenu by remember { mutableStateOf(false) }
var showRedirectMenu by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
var showFavoriteSheet by remember { mutableStateOf(false) }
var favoriteTitle by remember { mutableStateOf("") }
var favoriteGroup by remember { mutableStateOf("") }
val content by viewModel.contentState.collectAsStateWithLifecycle()
val analyzeUrl by viewModel.urlState.collectAsStateWithLifecycle()
val rssStar by viewModel.rssStarState.collectAsStateWithLifecycle()
val isSpeaking by viewModel.isSpeakingState.collectAsStateWithLifecycle()
val fallbackUserAgent = OtherConfig.userAgent
LaunchedEffect(origin, link, openUrl, title) {
viewModel.initData(
ReadRssArgs(
title = title,
origin = origin,
link = link,
openUrl = openUrl
)
)
}
LaunchedEffect(viewModel.rssSource?.redirectPolicy) {
redirectPolicy = RedirectPolicy.fromString(viewModel.rssSource?.redirectPolicy)
}
LaunchedEffect(content, webView, fallbackUserAgent) {
val body = content ?: return@LaunchedEffect
val currentWebView = webView ?: return@LaunchedEffect
currentWebView.settings.userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: fallbackUserAgent
val article = viewModel.rssArticle ?: return@LaunchedEffect
val url = NetworkUtils.getAbsoluteURL(article.origin, article.link)
val html = viewModel.clHtml(body)
if (viewModel.rssSource?.loadWithBaseUrl == true) {
currentWebView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
} else {
currentWebView.loadDataWithBaseURL(null, html, "text/html;charset=utf-8", "utf-8", url)
}
}
LaunchedEffect(analyzeUrl, webView) {
val url = analyzeUrl ?: return@LaunchedEffect
val currentWebView = webView ?: return@LaunchedEffect
CookieManager.applyToWebView(url.url)
currentWebView.settings.userAgentString = url.getUserAgent()
currentWebView.loadUrl(url.url, url.headerMap)
}
BackHandler {
when {
webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size ?: 0) > 1 -> webView?.goBack()
else -> onBackClick()
}
}
Column(modifier = Modifier.fillMaxSize()) {
GlassMediumFlexibleTopAppBar(
title = pageTitle.ifBlank { defaultTopBarTitle },
navigationIcon = {
TopBarNavigationButton(
onClick = onBackClick,
imageVector = Icons.AutoMirrored.Filled.ArrowBack
)
},
scrollBehavior = null,
actions = {
TopBarActionButton(
onClick = { viewModel.refresh { webView?.reload() } },
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.refresh)
)
TopBarActionButton(
onClick = {
viewModel.addFavorite()
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
showFavoriteSheet = true
},
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite)
)
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu"
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.share),
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
onClick = {
dismiss()
webView?.url?.let {
context.share(it)
} ?: viewModel.rssArticle?.let {
context.share(it.link)
} ?: context.toastOnUi(R.string.null_url)
}
)
RoundDropdownMenuItem(
text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource(
R.string.read_aloud
),
leadingIcon = {
MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp)
},
onClick = {
dismiss()
if (isSpeaking) {
viewModel.stopReadAloud()
} else {
webView?.evaluateJavascript("document.documentElement.outerHTML") {
val html = StringEscapeUtils.unescapeJson(it)
.replace("^\"|\"$".toRegex(), "")
viewModel.readAloud(
Jsoup.parse(html)
.text()
)
}
}
}
)
if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
onClick = {
dismiss()
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
}
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
RoundDropdownMenuItem(
text = stringResource(R.string.open_in_browser),
leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
onClick = {
dismiss()
webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
}
)
}
RoundDropdownMenu(
expanded = showRedirectMenu,
onDismissRequest = { showRedirectMenu = false }
) { dismiss ->
RedirectPolicy.entries.forEach { policy ->
RoundDropdownMenuItem(
text = policy.title(),
onClick = {
dismiss()
showMenu = false
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
context.toastOnUi("重定向策略已更新")
},
trailingIcon = {
if (policy == redirectPolicy) {
Icon(Icons.Default.Check, null)
}
}
)
}
}
}
)
Box(modifier = Modifier.fillMaxSize()) {
VisibleWebViewCompose(
modifier = Modifier.fillMaxSize(),
onCreated = { createdWebView ->
webView = createdWebView
configureRssReadWebView(
webView = createdWebView,
context = context,
activity = activity,
appCompatActivity = appCompatActivity,
viewModel = viewModel,
initialTitle = title,
redirectPolicyProvider = { redirectPolicy },
callbacks = RssReadWebControllerCallbacks(
onProgressChanged = { webProgress = it },
onPageTitleResolved = { resolved ->
pageTitle = resolved.ifBlank { defaultTopBarTitle }
}
)
)
}
)
if (webProgress in 0..99) {
LinearProgressIndicator(
progress = { webProgress / 100f },
modifier = Modifier.fillMaxWidth()
)
}
}
}
FavoriteEditSheet(
show = showFavoriteSheet,
titleValue = favoriteTitle,
groupValue = favoriteGroup,
onTitleChange = { favoriteTitle = it },
onGroupChange = { favoriteGroup = it },
onDismissRequest = { showFavoriteSheet = false },
onSave = {
viewModel.updateFavorite(favoriteTitle, favoriteGroup)
showFavoriteSheet = false
},
onDelete = {
viewModel.delFavorite()
showFavoriteSheet = false
}
)
}
@Composable
private fun FavoriteEditSheet(
show: Boolean,
titleValue: String,
groupValue: String,
onTitleChange: (String) -> Unit,
onGroupChange: (String) -> Unit,
onDismissRequest: () -> Unit,
onSave: () -> Unit,
onDelete: () -> Unit
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.favorite),
endAction = {
SmallIconButton(
onClick = onDelete,
imageVector = Icons.Default.CleaningServices,
contentDescription = stringResource(R.string.delete)
)
}
) {
Column(modifier = Modifier.padding(12.dp)) {
AppTextField(
value = titleValue,
onValueChange = onTitleChange,
label = stringResource(R.string.title),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
AppTextField(
value = groupValue,
onValueChange = onGroupChange,
label = stringResource(R.string.group_name),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.action_save),
leadingIcon = { MenuItemIcon(Icons.Default.Check) },
onClick = onSave,
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
)
}
}
@@ -0,0 +1,267 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.rss.read
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.net.http.SslError
import android.os.SystemClock
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.Download
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.utils.isTrue
import io.legado.app.utils.longSnackbar
import io.legado.app.utils.openUrl
import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import java.net.URLDecoder
internal data class RssReadWebControllerCallbacks(
val onProgressChanged: (Int) -> Unit,
val onPageTitleResolved: (String) -> Unit
)
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
internal fun configureRssReadWebView(
webView: VisibleWebView,
context: Context,
activity: Activity?,
appCompatActivity: AppCompatActivity?,
viewModel: ReadRssViewModel,
initialTitle: String?,
redirectPolicyProvider: () -> RedirectPolicy,
callbacks: RssReadWebControllerCallbacks
) {
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
callbacks.onProgressChanged(newProgress)
}
}
webView.webViewClient = object : WebViewClient() {
private var lastUrl: String? = null
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val targetUri = request.url
if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
return handleCustomScheme(targetUri)
}
val currentUrl = lastUrl ?: view.url
val targetUrl = request.url.toString()
lastUrl = targetUrl
if (!request.isForMainFrame) return false
if (handleRedirect(view, currentUrl, targetUrl)) return true
return handleCustomScheme(request.url)
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
val targetUri = url.toUri()
if (targetUri.scheme == "legado" || targetUri.scheme == "yuedu") {
return handleCustomScheme(targetUri)
}
val currentUrl = lastUrl ?: view.url
val targetUrl = url
lastUrl = targetUrl
if (handleRedirect(view, currentUrl, targetUrl)) return true
return handleCustomScheme(targetUri)
}
private fun handleRedirect(view: WebView, fromUrl: String?, toUrl: String): Boolean {
val fromHost = fromUrl?.toUri()?.host
val toHost = toUrl.toUri().host
val crossOrigin = fromHost != null && toHost != null && fromHost != toHost
return when (redirectPolicyProvider()) {
RedirectPolicy.ALLOW_ALL -> false
RedirectPolicy.BLOCK_ALL -> {
context.toastOnUi("已阻止重定向")
true
}
RedirectPolicy.ASK_ALWAYS -> {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
}
RedirectPolicy.ASK_CROSS_ORIGIN -> {
if (crossOrigin) {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
} else false
}
RedirectPolicy.BLOCK_CROSS_ORIGIN -> {
if (crossOrigin) {
context.toastOnUi("已阻止跨域重定向")
true
} else false
}
RedirectPolicy.ASK_SAME_DOMAIN_BLOCK_CROSS -> {
if (crossOrigin) {
context.toastOnUi("已阻止域外跳转")
true
} else {
askUser(fromUrl, toUrl) { if (it) view.loadUrl(toUrl) }
true
}
}
}
}
private fun askUser(fromUrl: String?, toUrl: String, onResult: (Boolean) -> Unit) {
AlertDialog.Builder(context)
.setTitle("重定向请求")
.setMessage("是否允许页面跳转?\n\n来源:${fromUrl ?: "未知"}\n目标:$toUrl")
.setPositiveButton("允许") { _, _ -> onResult(true) }
.setNegativeButton("拒绝") { _, _ -> onResult(false) }
.setCancelable(true)
.show()
}
private fun handleCustomScheme(url: Uri): Boolean {
val source = viewModel.rssSource
val js = source?.shouldOverrideUrlLoading
if (!js.isNullOrBlank() && appCompatActivity != null) {
val t = SystemClock.uptimeMillis()
val result = kotlin.runCatching {
runScriptWithContext(appCompatActivity.lifecycleScope.coroutineContext) {
source.evalJS(js) {
put("java", RssJsExtensions(appCompatActivity, source))
put("url", url.toString())
}.toString()
}
}.onFailure {
AppLog.put("${source.getTag()}: url跳转拦截js出错", it)
}.getOrNull()
if (SystemClock.uptimeMillis() - t > 30) {
AppLog.put("${source.getTag()}: url跳转拦截js执行耗时过长")
}
if (result.isTrue()) return true
}
return when (url.scheme) {
"http", "https", "jsbridge" -> false
"legado", "yuedu" -> {
context.startActivity<OnLineImportActivity> {
data = url
}
true
}
else -> {
val root = activity?.findViewById<View>(android.R.id.content)
if (root != null) {
root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
context.openUrl(url)
}
} else {
context.openUrl(url)
}
true
}
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
view.title?.let { webTitle ->
if (
webTitle != url &&
webTitle != view.url &&
webTitle.isNotBlank() &&
url != "about:blank"
) {
callbacks.onPageTitleResolved(webTitle)
} else {
callbacks.onPageTitleResolved(initialTitle.orEmpty())
}
}
viewModel.rssSource?.injectJs?.let {
if (it.isNotBlank()) {
view.evaluateJavascript(it, null)
}
}
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed()
}
}
webView.settings.apply {
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
allowContentAccess = true
builtInZoomControls = true
displayZoomControls = false
setDarkeningAllowed(AppConfig.isNightTheme)
if (viewModel.rssSource?.enableJs == true) {
javaScriptEnabled = true
}
}
webView.addJavascriptInterface(object {
@JavascriptInterface
fun isNightTheme(): Boolean = AppConfig.isNightTheme
}, "thisActivity")
webView.setOnLongClickListener {
val hitTestResult = webView.hitTestResult
if (
hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
) {
hitTestResult.extra?.let { webPic ->
appCompatActivity?.selector(
arrayListOf(
SelectItem(context.getString(R.string.action_save), "save"),
)
) { _, charSequence, _ ->
if (charSequence.value == "save") {
viewModel.saveImage(webPic)
}
}
return@setOnLongClickListener true
}
}
false
}
webView.setDownloadListener { url, _, contentDisposition, _, _ ->
var fileName = URLUtil.guessFileName(url, contentDisposition, null)
fileName = URLDecoder.decode(fileName, "UTF-8")
val root = appCompatActivity?.findViewById<View>(android.R.id.content)
if (root != null) {
root.longSnackbar(fileName, context.getString(R.string.action_download)) {
Download.start(context, url, fileName)
}
}
}
}
@@ -6,10 +6,13 @@ import android.util.AttributeSet
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import io.legado.app.R
import io.legado.app.ui.dict.DictDialog
import io.legado.app.utils.toastOnUi
@@ -20,6 +23,8 @@ class VisibleWebView(
attrs: AttributeSet? = null
) : WebView(context, attrs) {
private var lastSelectedText: String = ""
init {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
@@ -32,30 +37,26 @@ class VisibleWebView(
}, "TextSelectionBridge")
val js = """
document.addEventListener('selectionchange', function() {
const text = window.getSelection().toString();
if (text) {
TextSelectionBridge.onTextSelected(text);
}
});
"""
document.addEventListener('selectionchange', function() {
const text = window.getSelection().toString();
if (text) {
TextSelectionBridge.onTextSelected(text);
}
});
""".trimIndent()
evaluateJavascript(js, null)
}
private var lastSelectedText: String = ""
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(VISIBLE)
}
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
val wrappedCallback = createWrappedCallback(callback)
return super.startActionMode(wrappedCallback)
return super.startActionMode(createWrappedCallback(callback))
}
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
val wrappedCallback = createWrappedCallback(callback)
return super.startActionMode(wrappedCallback, type)
return super.startActionMode(createWrappedCallback(callback), type)
}
private fun createWrappedCallback(original: ActionMode.Callback?): ActionMode.Callback {
@@ -63,7 +64,7 @@ class VisibleWebView(
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val result = original?.onCreateActionMode(mode, menu) ?: false
menu.add(Menu.NONE, MENU_ID_DICT, 0, R.string.dict)
getSelectedText { /* 触发缓存*/ }
getSelectedText { }
return result
}
@@ -73,7 +74,7 @@ class VisibleWebView(
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
MENU_ID_DICT -> {
postDelayed({
getSelectedText { selectedText ->
@@ -85,13 +86,13 @@ class VisibleWebView(
}
}, 200)
mode.finish()
return true
true
}
else -> return original?.onActionItemClicked(mode, item) ?: false
else -> original?.onActionItemClicked(mode, item) ?: false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
original?.onDestroyActionMode(mode)
}
@@ -120,17 +121,42 @@ class VisibleWebView(
}
private fun showDictDialog(selectedText: String) {
val activity = context as? AppCompatActivity
activity?.let {
val dialog = DictDialog(selectedText)
it.supportFragmentManager.beginTransaction()
.add(dialog, "DictDialog")
.commitAllowingStateLoss()
}
val activity = context as? AppCompatActivity ?: return
val dialog = DictDialog(selectedText)
activity.supportFragmentManager.beginTransaction()
.add(dialog, "DictDialog")
.commitAllowingStateLoss()
}
companion object {
private const val MENU_ID_DICT = 1001
}
}
}
@Composable
fun VisibleWebViewCompose(
modifier: Modifier = Modifier,
onCreated: (VisibleWebView) -> Unit,
onDestroyed: (() -> Unit)? = null
) {
var webViewRef: VisibleWebView? = null
AndroidView(
modifier = modifier,
factory = { context ->
VisibleWebView(context).also {
webViewRef = it
onCreated(it)
}
},
update = {
webViewRef = it
}
)
DisposableEffect(Unit) {
onDispose {
onDestroyed?.invoke()
webViewRef?.destroy()
webViewRef = null
}
}
}
@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:fitsSystemWindows="true"
app:liftOnScroll="false"
android:stateListAnimator="@null">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/ctl_bar"
android:layout_width="match_parent"
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
style="?attr/collapsingToolbarLayoutMediumStyle"
app:collapsedTitleTextAppearance="?attr/textAppearanceTitleLarge"
app:expandedTitleTextAppearance="?attr/textAppearanceHeadlineLarge"
app:titleCollapseMode="scale"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
<com.google.android.material.appbar.MaterialToolbar
android:theme="@style/ThemeOverlay.Material3.Toolbar.CircleNavigationButton"
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/rss"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
<LinearLayout
android:id="@+id/ll_tab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="10dp">
<FrameLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clipToPadding="false"
android:clipChildren="false">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:tabMode="scrollable"
android:scrollbars="none" />
<View
android:id="@+id/tab_right_fade"
android:layout_width="40dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="@drawable/tab_right_fade" />
</FrameLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_expand_tabs"
android:layout_width="40dp"
android:layout_height="40dp"
android:checkable="true"
app:iconGravity="textStart"
style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
app:icon="@drawable/m3_split_button_chevron_avd" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:ignore="SpeakableTextPresentCheck" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/ll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<io.legado.app.ui.widget.TitleBar
android:id="@+id/title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<io.legado.app.ui.rss.read.VisibleWebView
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@+id/title_bar"
app:layout_constraintBottom_toBottomOf="parent" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:progress="100"
app:layout_constraintTop_toTopOf="@id/web_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/custom_web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/vw_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
tools:ignore="UselessParent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/tool_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarStyle"
app:title="@string/favorite" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="3dp">
<io.legado.app.lib.theme.view.ThemeEditText
android:id="@+id/edit_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/title"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout>
<io.legado.app.ui.widget.text.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="3dp">
<io.legado.app.lib.theme.view.ThemeEditText
android:id="@+id/edit_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/group_name"
tools:ignore="SpeakableTextPresentCheck,TouchTargetSizeCheck" />
</io.legado.app.ui.widget.text.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="12dp"
android:paddingRight="12dp"
app:flexWrap="wrap"
app:justifyContent="space_between">
<com.google.android.material.button.MaterialButton
android:id="@+id/tv_footer_left"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/delete"
tools:ignore="RtlHardcoded" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/tv_cancel"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/cancel"
tools:ignore="RtlHardcoded" />
<com.google.android.material.button.MaterialButton
android:id="@+id/tv_ok"
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ok"
tools:ignore="RtlHardcoded" />
</LinearLayout>
</com.google.android.flexbox.FlexboxLayout>
</LinearLayout>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<io.legado.app.ui.widget.recycler.RecyclerViewAtPager2
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="4dp"
android:clipToPadding="false" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_marginVertical="8dp"
android:layout_marginHorizontal="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:strokeWidth="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer">
<androidx.constraintlayout.widget.ConstraintLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/ChapterNameText"
android:ellipsize="end"
android:text="@string/app_name"
app:layout_constraintBottom_toTopOf="@+id/tv_pub_date"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/image_view"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_pub_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="8dp"
android:text="@string/app_name"
android:textSize="12sp"
android:textStyle="italic"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/image_view" />
<ImageView
android:id="@+id/image_view"
android:layout_width="110dp"
android:layout_height="68dp"
android:contentDescription="@string/img_cover"
android:paddingLeft="16dp"
android:visibility="gone"
android:scaleType="centerCrop"
android:src="@drawable/image_rss_article"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded,RtlSymmetry" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -1,56 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_margin="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:strokeWidth="0dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer">
<androidx.constraintlayout.widget.ConstraintLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/image_view"
android:layout_width="0dp"
android:layout_height="220dp"
android:contentDescription="@string/loading"
android:visibility="visible"
android:scaleType="centerCrop"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:ignore="RtlHardcoded,RtlSymmetry" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp"
android:maxLines="2"
android:ellipsize="end"
android:text="@string/app_name"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/image_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/tv_pub_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/app_name"
android:textSize="11sp"
android:maxLines="1"
app:layout_constraintTop_toBottomOf="@+id/tv_title"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:ignore="SmallSp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_margin="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:strokeWidth="0dp"
android:background="?attr/selectableItemBackground">
<androidx.constraintlayout.widget.ConstraintLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainer">
<ImageView
android:id="@+id/image_view"
android:layout_width="0dp"
android:layout_height="240dp"
android:contentDescription="@string/loading"
android:visibility="visible"
android:scaleType="centerCrop"
android:background="@drawable/bg_img_border"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:ignore="RtlHardcoded,RtlSymmetry" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="2"
android:ellipsize="end"
android:text="@string/app_name"
android:textSize="13sp"
android:paddingTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/image_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/tv_pub_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/app_name"
android:textSize="11sp"
android:maxLines="1"
android:paddingTop="4dp"
android:paddingBottom="2dp"
app:layout_constraintTop_toBottomOf="@+id/tv_title"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:ignore="SmallSp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_margin="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:strokeColor="?attr/colorSurfaceContainer"
app:cardBackgroundColor="?attr/colorSurfaceContainer">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cd_img"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="0dp"
app:strokeColor="?attr/colorSurfaceContainer"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<ImageView
android:id="@+id/image_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="80dp"
android:backgroundTint="?attr/colorSurfaceContainer"
android:contentDescription="@string/loading"
android:visibility="visible"
/>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="3"
android:ellipsize="end"
android:text="@string/app_name"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginHorizontal="8dp"
android:paddingTop="4dp"
app:layout_constraintTop_toBottomOf="@+id/cd_img"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/tv_pub_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="11sp"
android:paddingTop="4dp"
android:paddingBottom="10dp"
android:layout_marginHorizontal="8dp"
app:layout_constraintTop_toBottomOf="@+id/tv_title"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:ignore="SmallSp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item
android:id="@+id/menu_redirect_allow_all"
android:title="@string/redirect_allow_all" />
<item
android:id="@+id/menu_redirect_ask_always"
android:title="@string/redirect_ask_always" />
<item
android:id="@+id/menu_redirect_ask_cross_origin"
android:title="@string/redirect_ask_cross_origin" />
<item
android:id="@+id/menu_redirect_ask_same_domain_block_cross"
android:title="@string/redirect_ask_same_domain_block_cross" />
<item
android:id="@+id/menu_redirect_block_cross_origin"
android:title="@string/redirect_block_cross_origin" />
<item
android:id="@+id/menu_redirect_block_all"
android:title="@string/redirect_block_all" />
</group>
</menu>
-45
View File
@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_login"
android:title="@string/login"
app:showAsAction="never" />
<item
android:id="@+id/menu_refresh_sort"
android:title="@string/refresh_sort"
app:showAsAction="never" />
<item
android:id="@+id/menu_set_source_variable"
android:title="@string/set_source_variable"
app:showAsAction="never" />
<item
android:id="@+id/menu_edit_source"
android:title="@string/edit_source"
app:showAsAction="never" />
<item
android:id="@+id/menu_switch_layout"
android:title="@string/switchLayout"
app:showAsAction="never" />
<item
android:id="@+id/menu_read_record"
android:title="@string/read_record"
app:showAsAction="never" />
<item
android:id="@+id/menu_clear"
android:title="@string/clear"
app:showAsAction="never" />
<item
android:id="@+id/menu_redirect_policy"
android:title="@string/redirect_policy"
app:showAsAction="never" />
</menu>
-51
View File
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/menu_rss_refresh"
android:title="@string/refresh"
android:icon="@drawable/ic_refresh"
app:showAsAction="always" />
<item
android:id="@+id/menu_rss_star"
android:title="@string/favorite"
android:icon="@drawable/ic_star"
app:showAsAction="always" />
<item
android:id="@+id/menu_full_screen"
android:icon="@drawable/ic_fullscreen"
android:title="@string/full_screen"
app:showAsAction="always"
tools:ignore="AlwaysShowAction" />
<item
android:id="@+id/menu_share_it"
android:title="@string/share"
android:icon="@drawable/ic_share"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_aloud"
android:title="@string/read_aloud"
android:icon="@drawable/ic_volume_up"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_login"
android:title="@string/login"
app:showAsAction="never" />
<item
android:id="@+id/menu_redirect_policy"
android:title="@string/redirect_policy"
app:showAsAction="never" />
<item
android:id="@+id/menu_browser_open"
android:title="@string/open_in_browser"
app:showAsAction="never" />
</menu>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AlwaysShowAction">
<item
android:id="@+id/menu_clear"
android:title="@string/clear"
app:showAsAction="always" />
</menu>