[优化] 重写订阅阅读页面,可能有bug喵
This commit is contained in:
@@ -263,18 +263,6 @@
|
|||||||
android:name=".ui.book.searchContent.SearchContentActivity"
|
android:name=".ui.book.searchContent.SearchContentActivity"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:launchMode="singleTop" />
|
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
|
<activity
|
||||||
android:name=".ui.book.explore.ExploreShowActivity"
|
android:name=".ui.book.explore.ExploreShowActivity"
|
||||||
|
|||||||
@@ -105,13 +105,16 @@ interface BaseSource : JsExtensions {
|
|||||||
header?.let {
|
header?.let {
|
||||||
try {
|
try {
|
||||||
val json = when {
|
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.startsWith("<js>", true) -> evalJS(
|
||||||
it.substring(4, it.lastIndexOf("<"))
|
it.substring(4, it.lastIndexOf("<"))
|
||||||
).toString()
|
)?.toString().orEmpty()
|
||||||
|
|
||||||
else -> it
|
else -> it
|
||||||
}
|
}
|
||||||
|
if (json.isBlank()) {
|
||||||
|
return@let
|
||||||
|
}
|
||||||
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
||||||
putAll(map)
|
putAll(map)
|
||||||
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
||||||
@@ -119,7 +122,12 @@ interface BaseSource : JsExtensions {
|
|||||||
putAll(map)
|
putAll(map)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)) {
|
if (!has(AppConst.UA_NAME, true)) {
|
||||||
@@ -341,4 +349,4 @@ interface BaseSource : JsExtensions {
|
|||||||
}
|
}
|
||||||
return RhinoScriptEngine.eval(jsStr, scope)
|
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.ReplaceRuleViewModel
|
||||||
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
||||||
import io.legado.app.ui.rss.source.manage.RssSourceViewModel
|
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.singleOf
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
@@ -92,6 +95,9 @@ val appModule = module {
|
|||||||
|
|
||||||
viewModelOf(::DictRuleViewModel)
|
viewModelOf(::DictRuleViewModel)
|
||||||
viewModelOf(::RssSourceViewModel)
|
viewModelOf(::RssSourceViewModel)
|
||||||
|
viewModelOf(::RssSortViewModel)
|
||||||
|
viewModelOf(::RssArticlesViewModel)
|
||||||
|
viewModelOf(::ReadRssViewModel)
|
||||||
viewModelOf(::ReadRecordViewModel)
|
viewModelOf(::ReadRecordViewModel)
|
||||||
viewModelOf(::ExploreShowViewModel)
|
viewModelOf(::ExploreShowViewModel)
|
||||||
viewModelOf(::MyViewModel)
|
viewModelOf(::MyViewModel)
|
||||||
@@ -127,3 +133,4 @@ val appModule = module {
|
|||||||
|
|
||||||
viewModelOf(::SearchContentViewModel)
|
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.otherConfig.OtherConfigScreen
|
||||||
import io.legado.app.ui.config.readConfig.ReadConfigScreen
|
import io.legado.app.ui.config.readConfig.ReadConfigScreen
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
|
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.welcome.WelcomeActivity
|
||||||
import io.legado.app.ui.widget.dialog.TextDialog
|
import io.legado.app.ui.widget.dialog.TextDialog
|
||||||
import io.legado.app.utils.getPrefBoolean
|
import io.legado.app.utils.getPrefBoolean
|
||||||
@@ -98,7 +103,7 @@ open class MainActivity : BaseComposeActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel by viewModel<MainViewModel>()
|
private val viewModel by viewModel<MainViewModel>()
|
||||||
private val routeEvents = MutableSharedFlow<MainRoute>(extraBufferCapacity = 1)
|
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private sealed interface MainRoute : NavKey
|
private sealed interface MainRoute : NavKey
|
||||||
@@ -276,6 +281,16 @@ open class MainActivity : BaseComposeActivity() {
|
|||||||
},
|
},
|
||||||
onNavigateToLocalImport = {
|
onNavigateToLocalImport = {
|
||||||
navigateToRoute(backStack, MainRouteImportLocal)
|
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) }
|
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()
|
val currentRoute = backStack.lastOrNull()
|
||||||
if (currentRoute == route) return
|
if (currentRoute == route) return
|
||||||
|
|
||||||
@@ -367,6 +400,26 @@ open class MainActivity : BaseComposeActivity() {
|
|||||||
backStack.add(route)
|
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 {
|
private fun resolveStartRoute(intent: Intent?): NavKey {
|
||||||
return resolveStartRoute(intent?.getStringExtra(EXTRA_START_ROUTE))
|
val route = intent?.getStringExtra(EXTRA_START_ROUTE)
|
||||||
|
RssMainNavContract.resolveStartRoute(route, intent)?.let { return it }
|
||||||
|
return resolveStartRoute(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveStartRoute(route: String?): MainRoute {
|
private fun resolveStartRoute(route: String?): MainRoute {
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ fun MainScreen(
|
|||||||
useRail: Boolean,
|
useRail: Boolean,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onNavigateToRemoteImport: () -> Unit,
|
onNavigateToRemoteImport: () -> Unit,
|
||||||
onNavigateToLocalImport: () -> Unit
|
onNavigateToLocalImport: () -> Unit,
|
||||||
|
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -380,7 +381,11 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
MainDestination.Explore -> ExploreScreen()
|
MainDestination.Explore -> ExploreScreen()
|
||||||
MainDestination.Rss -> RssScreen()
|
MainDestination.Rss -> RssScreen(
|
||||||
|
onOpenSort = { sourceUrl, sortUrl, key ->
|
||||||
|
onNavigateToRssSort(sourceUrl, sortUrl, key)
|
||||||
|
}
|
||||||
|
)
|
||||||
MainDestination.My -> MyScreen(
|
MainDestination.My -> MyScreen(
|
||||||
viewModel = koinViewModel(),
|
viewModel = koinViewModel(),
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.data.entities.RssSource
|
import io.legado.app.data.entities.RssSource
|
||||||
import io.legado.app.ui.login.SourceLoginActivity
|
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.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.edit.RssSourceEditActivity
|
||||||
import io.legado.app.ui.rss.source.manage.RssSourceActivity
|
import io.legado.app.ui.rss.source.manage.RssSourceActivity
|
||||||
import io.legado.app.ui.rss.subscription.RuleSubActivity
|
import io.legado.app.ui.rss.subscription.RuleSubActivity
|
||||||
@@ -73,7 +72,8 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RssScreen(
|
fun RssScreen(
|
||||||
viewModel: RssViewModel = koinViewModel()
|
viewModel: RssViewModel = koinViewModel(),
|
||||||
|
onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -83,18 +83,19 @@ fun RssScreen(
|
|||||||
if (rssSource.singleUrl) {
|
if (rssSource.singleUrl) {
|
||||||
viewModel.getSingleUrl(rssSource) { url ->
|
viewModel.getSingleUrl(rssSource) { url ->
|
||||||
if (url.startsWith("http", true)) {
|
if (url.startsWith("http", true)) {
|
||||||
context.startActivity<ReadRssActivity> {
|
context.startActivity(
|
||||||
putExtra("title", rssSource.sourceName)
|
RssMainNavContract.createRssReadIntent(
|
||||||
putExtra("origin", url)
|
context = context,
|
||||||
}
|
title = rssSource.sourceName,
|
||||||
|
origin = url
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
context.openUrl(url)
|
context.openUrl(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
context.startActivity<RssSortActivity> {
|
onOpenSort(rssSource.sourceUrl, null, null)
|
||||||
putExtra("url", rssSource.sourceUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
package io.legado.app.ui.rss.article
|
||||||
|
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.activityViewModels
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.fragment.app.Fragment
|
||||||
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 io.legado.app.data.entities.RssArticle
|
import io.legado.app.data.entities.RssArticle
|
||||||
import io.legado.app.databinding.FragmentRssArticlesBinding
|
import io.legado.app.ui.rss.navigation.RssMainNavContract
|
||||||
import io.legado.app.databinding.ViewLoadMoreBinding
|
import io.legado.app.ui.theme.AppTheme
|
||||||
//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.utils.startActivity
|
import io.legado.app.utils.startActivity
|
||||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.fragment_rss_articles),
|
class RssArticlesFragment() : Fragment() {
|
||||||
BaseRssArticlesAdapter.CallBack {
|
|
||||||
|
|
||||||
constructor(sortName: String, sortUrl: String) : this() {
|
constructor(sortName: String, sortUrl: String) : this() {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
@@ -43,118 +24,49 @@ class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.frag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentRssArticlesBinding::bind)
|
private val activityViewModel: RssSortViewModel by activityViewModel()
|
||||||
private val activityViewModel by activityViewModels<RssSortViewModel>()
|
private val viewModel: RssArticlesViewModel by viewModel()
|
||||||
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
|
|
||||||
|
|
||||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onCreateView(
|
||||||
viewModel.init(arguments)
|
inflater: LayoutInflater,
|
||||||
initView()
|
container: ViewGroup?,
|
||||||
initData()
|
savedInstanceState: Bundle?
|
||||||
}
|
): View {
|
||||||
|
return ComposeView(requireContext()).apply {
|
||||||
private fun initView() = binding.run {
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
// refreshLayout.setColorSchemeColors(accentColor)
|
setContent {
|
||||||
// recyclerView.setEdgeEffectColor(primaryColor)
|
AppTheme {
|
||||||
recyclerView.applyNavigationBarPadding()
|
Content()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
||||||
refreshLayout.isRefreshing = true
|
|
||||||
loadArticles()
|
|
||||||
this@launch.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initData() {
|
@Composable
|
||||||
val rssUrl = activityViewModel.url ?: return
|
private fun Content() {
|
||||||
articlesFlowJob?.cancel()
|
val sortName = arguments?.getString("sortName").orEmpty()
|
||||||
articlesFlowJob = viewLifecycleOwner.lifecycleScope.launch {
|
val sortUrl = arguments?.getString("sortUrl").orEmpty()
|
||||||
appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).catch {
|
|
||||||
AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
|
RssArticlesPage(
|
||||||
}.flowOn(IO).collect {
|
sortName = sortName,
|
||||||
adapter.setItems(it)
|
sortUrl = sortUrl,
|
||||||
}
|
articleStyle = activityViewModel.currentArticleStyle(),
|
||||||
}
|
rssUrl = activityViewModel.url,
|
||||||
|
rssSource = activityViewModel.rssSource,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onRead = ::readRss
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadArticles() {
|
private fun readRss(rssArticle: RssArticle) {
|
||||||
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) {
|
|
||||||
activityViewModel.read(rssArticle)
|
activityViewModel.read(rssArticle)
|
||||||
startActivity<ReadRssActivity> {
|
requireContext().startActivity(
|
||||||
putExtra("title", rssArticle.title)
|
RssMainNavContract.createRssReadIntent(
|
||||||
putExtra("origin", rssArticle.origin)
|
context = requireContext(),
|
||||||
putExtra("link", rssArticle.link)
|
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.app.Application
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.constant.AppLog
|
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.model.rss.Rss
|
||||||
import io.legado.app.utils.stackTraceStr
|
import io.legado.app.utils.stackTraceStr
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
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) {
|
class RssArticlesViewModel(application: Application) : BaseViewModel(application) {
|
||||||
val loadFinallyLiveData = MutableLiveData<Boolean>()
|
|
||||||
val loadErrorLiveData = MutableLiveData<String>()
|
private val _loadState = MutableStateFlow(RssArticlesLoadState())
|
||||||
var isLoading = true
|
val loadState: StateFlow<RssArticlesLoadState> = _loadState.asStateFlow()
|
||||||
|
|
||||||
var order = System.currentTimeMillis()
|
var order = System.currentTimeMillis()
|
||||||
private var nextPageUrl: String? = null
|
private var nextPageUrl: String? = null
|
||||||
var sortName: String = ""
|
var sortName: String = ""
|
||||||
@@ -26,13 +28,29 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
|
|||||||
|
|
||||||
fun init(bundle: Bundle?) {
|
fun init(bundle: Bundle?) {
|
||||||
bundle?.let {
|
bundle?.let {
|
||||||
sortName = it.getString("sortName") ?: ""
|
init(
|
||||||
sortUrl = it.getString("sortUrl") ?: ""
|
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) {
|
fun loadArticles(rssSource: RssSource) {
|
||||||
isLoading = true
|
_loadState.value = _loadState.value.copy(
|
||||||
|
isRefreshing = true,
|
||||||
|
isLoadingMore = false,
|
||||||
|
errorMessage = null,
|
||||||
|
hasMore = true
|
||||||
|
)
|
||||||
page = 1
|
page = 1
|
||||||
order = System.currentTimeMillis()
|
order = System.currentTimeMillis()
|
||||||
Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) {
|
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)
|
appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order)
|
||||||
}
|
}
|
||||||
val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty()
|
val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty()
|
||||||
loadFinallyLiveData.postValue(hasMore)
|
_loadState.value = _loadState.value.copy(
|
||||||
isLoading = false
|
isRefreshing = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
hasMore = hasMore,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
}.onError {
|
}.onError {
|
||||||
loadFinallyLiveData.postValue(false)
|
|
||||||
AppLog.put("rss获取内容失败", it)
|
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) {
|
fun loadMore(rssSource: RssSource) {
|
||||||
isLoading = true
|
val currentState = _loadState.value
|
||||||
page++
|
if (currentState.isRefreshing || currentState.isLoadingMore || !currentState.hasMore) return
|
||||||
|
|
||||||
val pageUrl = nextPageUrl
|
val pageUrl = nextPageUrl
|
||||||
if (pageUrl.isNullOrEmpty()) {
|
if (pageUrl.isNullOrEmpty()) {
|
||||||
loadFinallyLiveData.postValue(false)
|
_loadState.value = currentState.copy(hasMore = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_loadState.value = currentState.copy(
|
||||||
|
isLoadingMore = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
|
||||||
|
page++
|
||||||
Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
|
Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
|
||||||
nextPageUrl = it.second
|
nextPageUrl = it.second
|
||||||
loadMoreSuccess(it.first)
|
loadMoreSuccess(it.first)
|
||||||
isLoading = false
|
|
||||||
}.onError {
|
}.onError {
|
||||||
loadFinallyLiveData.postValue(false)
|
|
||||||
AppLog.put("rss获取内容失败", it)
|
AppLog.put("rss获取内容失败", it)
|
||||||
loadErrorLiveData.postValue(it.stackTraceStr)
|
_loadState.value = _loadState.value.copy(
|
||||||
|
isLoadingMore = false,
|
||||||
|
errorMessage = it.stackTraceStr
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMoreSuccess(articles: MutableList<RssArticle>) {
|
private fun loadMoreSuccess(articles: MutableList<RssArticle>) {
|
||||||
if (articles.isEmpty()) {
|
if (articles.isEmpty()) {
|
||||||
loadFinallyLiveData.postValue(false)
|
_loadState.value = _loadState.value.copy(
|
||||||
|
isLoadingMore = false,
|
||||||
|
hasMore = false
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val firstArticle = articles.first()
|
val firstArticle = articles.first()
|
||||||
val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
|
val dbFirstArticle = appDb.rssArticleDao.getByLink(firstArticle.origin, firstArticle.link)
|
||||||
val lastArticle = articles.last()
|
val lastArticle = articles.last()
|
||||||
val dbLastArticle = appDb.rssArticleDao.getByLink(lastArticle.origin, lastArticle.link)
|
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.RssReadRecord
|
||||||
import io.legado.app.data.entities.RssSource
|
import io.legado.app.data.entities.RssSource
|
||||||
import io.legado.app.help.source.removeSortCache
|
import io.legado.app.help.source.removeSortCache
|
||||||
|
import io.legado.app.help.source.sortUrls
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
|
|
||||||
|
|
||||||
class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
||||||
var url: String? = null
|
var url: String? = null
|
||||||
var rssSource: RssSource? = null
|
var rssSource: RssSource? = null
|
||||||
@@ -22,14 +22,18 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
val isWaterLayout get() = rssSource?.articleStyle == 3
|
val isWaterLayout get() = rssSource?.articleStyle == 3
|
||||||
|
|
||||||
fun initData(intent: Intent, finally: () -> Unit) {
|
fun initData(intent: Intent, finally: () -> Unit) {
|
||||||
|
initData(intent.getStringExtra("url") ?: intent.getStringExtra("sourceUrl"), finally)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initData(sourceUrl: String?, finally: () -> Unit) {
|
||||||
execute {
|
execute {
|
||||||
url = intent.getStringExtra("url")
|
url = sourceUrl
|
||||||
url?.let { url ->
|
url?.let { sourceUrl ->
|
||||||
rssSource = appDb.rssSourceDao.getByKey(url)
|
rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
|
||||||
rssSource?.let {
|
rssSource?.let {
|
||||||
titleLiveData.postValue(it.sourceName)
|
titleLiveData.postValue(it.sourceName)
|
||||||
} ?: let {
|
} ?: let {
|
||||||
rssSource = RssSource(sourceUrl = url)
|
rssSource = RssSource(sourceUrl = sourceUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFinally {
|
}.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() {
|
fun switchLayout() {
|
||||||
rssSource?.let {
|
rssSource?.let {
|
||||||
if (it.articleStyle < 3) {
|
if (it.articleStyle < 3) {
|
||||||
@@ -67,8 +77,6 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
appDb.rssArticleDao.delete(it)
|
appDb.rssArticleDao.delete(it)
|
||||||
}
|
}
|
||||||
order = System.currentTimeMillis()
|
order = System.currentTimeMillis()
|
||||||
}.onSuccess {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +92,7 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
return appDb.rssReadRecordDao.getRecords()
|
return appDb.rssReadRecordDao.getRecords()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun countRecords() : Int {
|
fun countRecords(): Int {
|
||||||
return appDb.rssReadRecordDao.countRecords
|
return appDb.rssReadRecordDao.countRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +110,4 @@ class RssSortViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
appCtx.toastOnUi("保存失败: ${it.localizedMessage}")
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.data.entities.RssStar
|
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.ActionItem
|
||||||
import io.legado.app.ui.widget.components.EmptyMessage
|
import io.legado.app.ui.widget.components.EmptyMessage
|
||||||
import io.legado.app.ui.widget.components.SourceIcon
|
import io.legado.app.ui.widget.components.SourceIcon
|
||||||
@@ -224,11 +224,14 @@ fun RssFavoritesScreen(
|
|||||||
} else null,
|
} else null,
|
||||||
trailingAction = {
|
trailingAction = {
|
||||||
val openAction = {
|
val openAction = {
|
||||||
context.startActivity<ReadRssActivity> {
|
context.startActivity(
|
||||||
putExtra("title", rssStar.title)
|
RssMainNavContract.createRssReadIntent(
|
||||||
putExtra("origin", rssStar.origin)
|
context = context,
|
||||||
putExtra("link", rssStar.link)
|
title = rssStar.title,
|
||||||
}
|
origin = rssStar.origin,
|
||||||
|
link = rssStar.link
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
SmallIconButton(
|
SmallIconButton(
|
||||||
onClick = openAction,
|
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.content.Intent
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.script.rhino.runScriptWithContext
|
import com.script.rhino.runScriptWithContext
|
||||||
import io.legado.app.base.BaseViewModel
|
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.ImageSaveUtils
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import kotlin.coroutines.coroutineContext
|
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) {
|
class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
||||||
var rssSource: RssSource? = null
|
var rssSource: RssSource? = null
|
||||||
var rssArticle: RssArticle? = null
|
var rssArticle: RssArticle? = null
|
||||||
var tts: TTS? = 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()
|
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) {
|
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 {
|
execute {
|
||||||
val origin = intent.getStringExtra("origin") ?: return@execute
|
rssSource = appDb.rssSourceDao.getByKey(args.origin)
|
||||||
val link = intent.getStringExtra("link")
|
|
||||||
rssSource = appDb.rssSourceDao.getByKey(origin)
|
|
||||||
headerMap = runScriptWithContext {
|
headerMap = runScriptWithContext {
|
||||||
rssSource?.getHeaderMap() ?: emptyMap()
|
rssSource?.getHeaderMap() ?: emptyMap()
|
||||||
}
|
}
|
||||||
if (link != null) {
|
|
||||||
rssStar = appDb.rssStarDao.get(origin, link)
|
val link = args.link
|
||||||
rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.getByLink(origin, link)
|
if (!link.isNullOrBlank()) {
|
||||||
val rssArticle = rssArticle ?: return@execute
|
_rssStarState.value = appDb.rssStarDao.get(args.origin, link)
|
||||||
if (!rssArticle.description.isNullOrBlank()) {
|
rssArticle = _rssStarState.value?.toRssArticle() ?: appDb.rssArticleDao.getByLink(args.origin, link)
|
||||||
contentLiveData.postValue(rssArticle.description!!)
|
val article = rssArticle ?: return@execute
|
||||||
|
if (!article.description.isNullOrBlank()) {
|
||||||
|
_contentState.value = article.description!!
|
||||||
} else {
|
} else {
|
||||||
rssSource?.let {
|
rssSource?.let {
|
||||||
val ruleContent = it.ruleContent
|
val ruleContent = it.ruleContent
|
||||||
if (!ruleContent.isNullOrBlank()) {
|
if (!ruleContent.isNullOrBlank()) {
|
||||||
loadContent(rssArticle, ruleContent)
|
loadContent(article, ruleContent)
|
||||||
} else {
|
} else {
|
||||||
loadUrl(rssArticle.link, rssArticle.origin)
|
loadUrl(article.link, article.origin)
|
||||||
}
|
}
|
||||||
} ?: loadUrl(rssArticle.link, rssArticle.origin)
|
} ?: loadUrl(article.link, article.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)
|
|
||||||
}
|
}
|
||||||
|
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,
|
coroutineContext = coroutineContext,
|
||||||
hasLoginHeader = false
|
hasLoginHeader = false
|
||||||
)
|
)
|
||||||
urlLiveData.postValue(analyzeUrl)
|
_urlState.value = analyzeUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadContent(rssArticle: RssArticle, ruleContent: String) {
|
private fun loadContent(rssArticle: RssArticle, ruleContent: String) {
|
||||||
@@ -94,22 +128,22 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
.onSuccess(IO) { body ->
|
.onSuccess(IO) { body ->
|
||||||
rssArticle.description = body
|
rssArticle.description = body
|
||||||
appDb.rssArticleDao.insert(rssArticle)
|
appDb.rssArticleDao.insert(rssArticle)
|
||||||
rssStar?.let {
|
_rssStarState.value?.let {
|
||||||
it.description = body
|
it.description = body
|
||||||
appDb.rssStarDao.insert(it)
|
appDb.rssStarDao.insert(it)
|
||||||
}
|
}
|
||||||
contentLiveData.postValue(body)
|
_contentState.value = body
|
||||||
}.onError {
|
}.onError {
|
||||||
contentLiveData.postValue("加载正文失败\n${it.stackTraceToString()}")
|
_contentState.value = "加载正文失败\n${it.stackTraceToString()}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(finish: () -> Unit) {
|
fun refresh(finish: () -> Unit) {
|
||||||
rssArticle?.let { rssArticle ->
|
rssArticle?.let { article ->
|
||||||
rssSource?.let {
|
rssSource?.let {
|
||||||
val ruleContent = it.ruleContent
|
val ruleContent = it.ruleContent
|
||||||
if (!ruleContent.isNullOrBlank()) {
|
if (!ruleContent.isNullOrBlank()) {
|
||||||
loadContent(rssArticle, ruleContent)
|
loadContent(article, ruleContent)
|
||||||
} else {
|
} else {
|
||||||
finish.invoke()
|
finish.invoke()
|
||||||
}
|
}
|
||||||
@@ -120,50 +154,38 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
} ?: finish.invoke()
|
} ?: 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() {
|
fun addFavorite() {
|
||||||
execute {
|
execute {
|
||||||
rssStar ?: rssArticle?.toStar()?.let {
|
_rssStarState.value ?: rssArticle?.toStar()?.let {
|
||||||
appDb.rssStarDao.insert(it)
|
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 {
|
execute {
|
||||||
rssArticle?.toStar()?.let {
|
rssArticle?.toStar()?.let {
|
||||||
appDb.rssStarDao.update(it)
|
appDb.rssStarDao.update(it)
|
||||||
rssStar = it
|
_rssStarState.value = it
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
|
||||||
upStarMenuData.postValue(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delFavorite() {
|
fun delFavorite() {
|
||||||
execute {
|
execute {
|
||||||
rssStar?.let {
|
_rssStarState.value?.let {
|
||||||
appDb.rssStarDao.delete(it.origin, it.link)
|
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
|
webPic ?: return
|
||||||
execute {
|
execute {
|
||||||
val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL")
|
val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL")
|
||||||
|
|
||||||
val success = ImageSaveUtils.saveImageToGallery(
|
val success = ImageSaveUtils.saveImageToGallery(
|
||||||
context,
|
context,
|
||||||
byteArray,
|
byteArray,
|
||||||
folderName = "Legado" // 可以自定义相册子目录
|
folderName = "Legado"
|
||||||
)
|
)
|
||||||
if (!success) throw NoStackTraceException("保存到相册失败")
|
if (!success) throw NoStackTraceException("保存到相册失败")
|
||||||
}.onError {
|
}.onError {
|
||||||
@@ -185,7 +206,6 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun webData2bitmap(data: String): ByteArray? {
|
private suspend fun webData2bitmap(data: String): ByteArray? {
|
||||||
return if (URLUtil.isValidUrl(data)) {
|
return if (URLUtil.isValidUrl(data)) {
|
||||||
okHttpClient.newCallResponseBody {
|
okHttpClient.newCallResponseBody {
|
||||||
@@ -230,11 +250,11 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
tts = TTS().apply {
|
tts = TTS().apply {
|
||||||
setSpeakStateListener(object : TTS.SpeakStateListener {
|
setSpeakStateListener(object : TTS.SpeakStateListener {
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
upTtsMenuData.postValue(true)
|
_isSpeakingState.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
upTtsMenuData.postValue(false)
|
_isSpeakingState.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -242,6 +262,11 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
tts?.speak(text)
|
tts?.speak(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun stopReadAloud() {
|
||||||
|
tts?.stop()
|
||||||
|
_isSpeakingState.value = false
|
||||||
|
}
|
||||||
|
|
||||||
fun updateRssSourceRedirectPolicy(sourceUrl: String, redirectPolicy: String) {
|
fun updateRssSourceRedirectPolicy(sourceUrl: String, redirectPolicy: String) {
|
||||||
execute {
|
execute {
|
||||||
appDb.rssSourceDao.updateRedirectPolicy(sourceUrl, redirectPolicy)
|
appDb.rssSourceDao.updateRedirectPolicy(sourceUrl, redirectPolicy)
|
||||||
@@ -255,5 +280,4 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
super.onCleared()
|
super.onCleared()
|
||||||
tts?.clearTts()
|
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.explore.ExploreShowActivity
|
||||||
import io.legado.app.ui.book.search.SearchActivity
|
import io.legado.app.ui.book.search.SearchActivity
|
||||||
import io.legado.app.ui.login.SourceLoginActivity
|
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.ui.widget.dialog.PhotoDialog
|
||||||
import io.legado.app.utils.isJsonObject
|
import io.legado.app.utils.isJsonObject
|
||||||
import io.legado.app.utils.showDialogFragment
|
import io.legado.app.utils.showDialogFragment
|
||||||
@@ -127,7 +127,13 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
|
|||||||
}
|
}
|
||||||
val sourceUrl = toSource.sourceUrl
|
val sourceUrl = toSource.sourceUrl
|
||||||
withContext(Main) {
|
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) //留下历史记录
|
appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录
|
||||||
withContext(Main) {
|
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.ActionMode
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.R
|
||||||
import io.legado.app.ui.dict.DictDialog
|
import io.legado.app.ui.dict.DictDialog
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
@@ -20,6 +23,8 @@ class VisibleWebView(
|
|||||||
attrs: AttributeSet? = null
|
attrs: AttributeSet? = null
|
||||||
) : WebView(context, attrs) {
|
) : WebView(context, attrs) {
|
||||||
|
|
||||||
|
private var lastSelectedText: String = ""
|
||||||
|
|
||||||
init {
|
init {
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
@@ -32,30 +37,26 @@ class VisibleWebView(
|
|||||||
}, "TextSelectionBridge")
|
}, "TextSelectionBridge")
|
||||||
|
|
||||||
val js = """
|
val js = """
|
||||||
document.addEventListener('selectionchange', function() {
|
document.addEventListener('selectionchange', function() {
|
||||||
const text = window.getSelection().toString();
|
const text = window.getSelection().toString();
|
||||||
if (text) {
|
if (text) {
|
||||||
TextSelectionBridge.onTextSelected(text);
|
TextSelectionBridge.onTextSelected(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
"""
|
""".trimIndent()
|
||||||
evaluateJavascript(js, null)
|
evaluateJavascript(js, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastSelectedText: String = ""
|
|
||||||
|
|
||||||
override fun onWindowVisibilityChanged(visibility: Int) {
|
override fun onWindowVisibilityChanged(visibility: Int) {
|
||||||
super.onWindowVisibilityChanged(VISIBLE)
|
super.onWindowVisibilityChanged(VISIBLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
|
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
|
||||||
val wrappedCallback = createWrappedCallback(callback)
|
return super.startActionMode(createWrappedCallback(callback))
|
||||||
return super.startActionMode(wrappedCallback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
|
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
|
||||||
val wrappedCallback = createWrappedCallback(callback)
|
return super.startActionMode(createWrappedCallback(callback), type)
|
||||||
return super.startActionMode(wrappedCallback, type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createWrappedCallback(original: ActionMode.Callback?): ActionMode.Callback {
|
private fun createWrappedCallback(original: ActionMode.Callback?): ActionMode.Callback {
|
||||||
@@ -63,7 +64,7 @@ class VisibleWebView(
|
|||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
val result = original?.onCreateActionMode(mode, menu) ?: false
|
val result = original?.onCreateActionMode(mode, menu) ?: false
|
||||||
menu.add(Menu.NONE, MENU_ID_DICT, 0, R.string.dict)
|
menu.add(Menu.NONE, MENU_ID_DICT, 0, R.string.dict)
|
||||||
getSelectedText { /* 触发缓存*/ }
|
getSelectedText { }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ class VisibleWebView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
when (item.itemId) {
|
return when (item.itemId) {
|
||||||
MENU_ID_DICT -> {
|
MENU_ID_DICT -> {
|
||||||
postDelayed({
|
postDelayed({
|
||||||
getSelectedText { selectedText ->
|
getSelectedText { selectedText ->
|
||||||
@@ -85,13 +86,13 @@ class VisibleWebView(
|
|||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
mode.finish()
|
mode.finish()
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
else -> return original?.onActionItemClicked(mode, item) ?: false
|
|
||||||
|
else -> original?.onActionItemClicked(mode, item) ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
original?.onDestroyActionMode(mode)
|
original?.onDestroyActionMode(mode)
|
||||||
}
|
}
|
||||||
@@ -120,17 +121,42 @@ class VisibleWebView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showDictDialog(selectedText: String) {
|
private fun showDictDialog(selectedText: String) {
|
||||||
val activity = context as? AppCompatActivity
|
val activity = context as? AppCompatActivity ?: return
|
||||||
activity?.let {
|
val dialog = DictDialog(selectedText)
|
||||||
val dialog = DictDialog(selectedText)
|
activity.supportFragmentManager.beginTransaction()
|
||||||
it.supportFragmentManager.beginTransaction()
|
.add(dialog, "DictDialog")
|
||||||
.add(dialog, "DictDialog")
|
.commitAllowingStateLoss()
|
||||||
.commitAllowingStateLoss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MENU_ID_DICT = 1001
|
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