[优化] 重写订阅阅读页面,可能有bug喵
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user