This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,16 @@
package io.legato.kazusa.ui.rss.article
import android.content.Context
import androidx.viewbinding.ViewBinding
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.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,88 @@
package io.legato.kazusa.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.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.RssReadRecord
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemRssReadRecordBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class ReadRecordDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModels<RssSortViewModel>()
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy {
ReadRecordAdapter(requireContext())
}
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.read_record)
toolBar.inflateMenu(R.menu.rss_read_record)
toolBar.setOnMenuItemClickListener(this@ReadRecordDialog)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
}
adapter.setItems(viewModel.getRecords())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_clear -> {
alert(R.string.draw) {
val countRead = viewModel.countRecords()
setMessage(getString(R.string.sure_del) + "\n" + countRead + " " + getString(R.string.read_record))
noButton()
yesButton {
viewModel.deleteAllRecord()
adapter.clearItems()
}
}
}
}
return true
}
inner class ReadRecordAdapter(context: Context) :
RecyclerAdapter<RssReadRecord, ItemRssReadRecordBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemRssReadRecordBinding {
return ItemRssReadRecordBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssReadRecordBinding,
item: RssReadRecord,
payloads: MutableList<Any>
) {
binding.textTitle.text = item.title
binding.textRecord.text = item.record
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssReadRecordBinding) {
}
}
}
@@ -0,0 +1,91 @@
package io.legato.kazusa.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.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.databinding.ItemRssArticleBinding
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.help.glide.OkHttpModelLoader
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.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.getCompatColor(R.color.tv_text_summary))
} else {
tvTitle.setTextColor(context.getCompatColor(R.color.primaryText))
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -0,0 +1,90 @@
package io.legato.kazusa.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.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.databinding.ItemRssArticle1Binding
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.help.glide.OkHttpModelLoader
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class RssArticlesAdapter1(context: Context, callBack: CallBack) :
BaseRssArticlesAdapter<ItemRssArticle1Binding>(context, callBack) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticle1Binding {
return ItemRssArticle1Binding.inflate(inflater, parent, false)
}
@SuppressLint("CheckResult")
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticle1Binding,
item: RssArticle,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank() && !callBack.isGridLayout) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image).apply(options).apply {
if (callBack.isGridLayout) {
placeholder(R.drawable.image_rss_article)
} else {
addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
}
}.into(imageView)
}
// if (item.read) {
// tvTitle.setTextColor(context.getCompatColor(R.color.tv_text_summary))
// } else {
// tvTitle.setTextColor(context.getCompatColor(R.color.primaryText))
// }
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle1Binding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -0,0 +1,91 @@
package io.legato.kazusa.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.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.databinding.ItemRssArticle2Binding
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.help.glide.OkHttpModelLoader
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.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.getCompatColor(R.color.tv_text_summary))
} else {
tvTitle.setTextColor(context.getCompatColor(R.color.primaryText))
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticle2Binding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
}
}
@@ -0,0 +1,157 @@
package io.legato.kazusa.ui.rss.article
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.databinding.FragmentRssArticlesBinding
import io.legato.kazusa.databinding.ViewLoadMoreBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.rss.read.ReadRssActivity
import io.legato.kazusa.ui.widget.recycler.LoadMoreView
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
class RssArticlesFragment() : VMBaseFragment<RssArticlesViewModel>(R.layout.fragment_rss_articles),
BaseRssArticlesAdapter.CallBack {
constructor(sortName: String, sortUrl: String) : this() {
arguments = Bundle().apply {
putString("sortName", sortName)
putString("sortUrl", sortUrl)
}
}
private val binding by viewBinding(FragmentRssArticlesBinding::bind)
private val activityViewModel by activityViewModels<RssSortViewModel>()
override val viewModel by viewModels<RssArticlesViewModel>()
private val adapter: BaseRssArticlesAdapter<*> by lazy {
when (activityViewModel.rssSource?.articleStyle) {
1 -> RssArticlesAdapter1(requireContext(), this@RssArticlesFragment)
2 -> RssArticlesAdapter2(requireContext(), this@RssArticlesFragment)
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?) {
viewModel.init(arguments)
initView()
initData()
}
private fun initView() = binding.run {
// refreshLayout.setColorSchemeColors(accentColor)
// recyclerView.setEdgeEffectColor(primaryColor)
recyclerView.applyNavigationBarPadding()
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading) {
scrollToBottom(true)
}
}
recyclerView.layoutManager = if (activityViewModel.isGridLayout) {
recyclerView.setPadding(8, 0, 8, 0)
GridLayoutManager(requireContext(), 2)
} else {
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
LinearLayoutManager(requireContext())
}
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() {
val rssUrl = activityViewModel.url ?: return
articlesFlowJob?.cancel()
articlesFlowJob = viewLifecycleOwner.lifecycleScope.launch {
appDb.rssArticleDao.flowByOriginSort(rssUrl, viewModel.sortName).catch {
AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun loadArticles() {
activityViewModel.rssSource?.let {
viewModel.loadArticles(it)
}
}
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)
startActivity<ReadRssActivity> {
putExtra("title", rssArticle.title)
putExtra("origin", rssArticle.origin)
putExtra("link", rssArticle.link)
}
}
}
@@ -0,0 +1,96 @@
package io.legato.kazusa.ui.rss.article
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.model.rss.Rss
import io.legato.kazusa.utils.stackTraceStr
import kotlinx.coroutines.Dispatchers.IO
class RssArticlesViewModel(application: Application) : BaseViewModel(application) {
val loadFinallyLiveData = MutableLiveData<Boolean>()
val loadErrorLiveData = MutableLiveData<String>()
var isLoading = true
var order = System.currentTimeMillis()
private var nextPageUrl: String? = null
var sortName: String = ""
var sortUrl: String = ""
var page = 1
fun init(bundle: Bundle?) {
bundle?.let {
sortName = it.getString("sortName") ?: ""
sortUrl = it.getString("sortUrl") ?: ""
}
}
fun loadArticles(rssSource: RssSource) {
isLoading = true
page = 1
order = System.currentTimeMillis()
Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) {
nextPageUrl = it.second
val articles = it.first
articles.forEach { rssArticle ->
rssArticle.order = order--
}
appDb.rssArticleDao.insert(*articles.toTypedArray())
if (!rssSource.ruleNextPage.isNullOrEmpty()) {
appDb.rssArticleDao.clearOld(rssSource.sourceUrl, sortName, order)
}
val hasMore = articles.isNotEmpty() && !rssSource.ruleNextPage.isNullOrEmpty()
loadFinallyLiveData.postValue(hasMore)
isLoading = false
}.onError {
loadFinallyLiveData.postValue(false)
AppLog.put("rss获取内容失败", it)
loadErrorLiveData.postValue(it.stackTraceStr)
}
}
fun loadMore(rssSource: RssSource) {
isLoading = true
page++
val pageUrl = nextPageUrl
if (pageUrl.isNullOrEmpty()) {
loadFinallyLiveData.postValue(false)
return
}
Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
nextPageUrl = it.second
loadMoreSuccess(it.first)
isLoading = false
}.onError {
loadFinallyLiveData.postValue(false)
AppLog.put("rss获取内容失败", it)
loadErrorLiveData.postValue(it.stackTraceStr)
}
}
private fun loadMoreSuccess(articles: MutableList<RssArticle>) {
if (articles.isEmpty()) {
loadFinallyLiveData.postValue(false)
return
}
val firstArticle = articles.first()
val dbFirstArticle = appDb.rssArticleDao.get(firstArticle.origin, firstArticle.link)
val lastArticle = articles.last()
val dbLastArticle = appDb.rssArticleDao.get(lastArticle.origin, lastArticle.link)
if (dbFirstArticle != null && dbLastArticle != null) {
loadFinallyLiveData.postValue(false)
} else {
articles.forEach {
it.order = order--
}
appDb.rssArticleDao.append(*articles.toTypedArray())
}
}
}
@@ -0,0 +1,168 @@
@file:Suppress("DEPRECATION")
package io.legato.kazusa.ui.rss.article
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.databinding.ActivityRssArtivlesBinding
import io.legato.kazusa.help.source.sortUrls
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.rss.source.edit.RssSourceEditActivity
import io.legato.kazusa.ui.widget.dialog.VariableDialog
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
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 val adapter by lazy { TabFragmentPageAdapter() }
private val sortList = mutableListOf<Pair<String, String>>()
private val fragmentMap = hashMapOf<String, Fragment>()
private val editSourceResult = registerForActivityResult(
StartActivityContract(RssSourceEditActivity::class.java)
) {
if (it.resultCode == RESULT_OK) {
viewModel.initData(intent) {
upFragments()
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
//binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
viewModel.titleLiveData.observe(this) {
binding.titleBar.title = it
}
viewModel.initData(intent) {
upFragments()
}
}
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_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 upFragments() {
lifecycleScope.launch {
viewModel.rssSource?.sortUrls()?.let {
sortList.clear()
sortList.addAll(it)
}
if (sortList.size == 1) {
binding.tabLayout.gone()
} else {
binding.tabLayout.visible()
}
adapter.notifyDataSetChanged()
}
}
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 inner class TabFragmentPageAdapter :
FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getItemPosition(`object`: Any): Int {
return POSITION_NONE
}
override fun getPageTitle(position: Int): CharSequence {
return sortList[position].first
}
override fun getItem(position: Int): Fragment {
val sort = sortList[position]
return RssArticlesFragment(sort.first, sort.second)
}
override fun getCount(): Int {
return sortList.size
}
override fun instantiateItem(container: ViewGroup, position: Int): Any {
val fragment = super.instantiateItem(container, position) as Fragment
fragmentMap[sortList[position].first] = fragment
return fragment
}
}
}
@@ -0,0 +1,94 @@
package io.legato.kazusa.ui.rss.article
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.data.entities.RssReadRecord
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.help.source.removeSortCache
class RssSortViewModel(application: Application) : BaseViewModel(application) {
var url: String? = null
var rssSource: RssSource? = null
val titleLiveData = MutableLiveData<String>()
var order = System.currentTimeMillis()
val isGridLayout get() = rssSource?.articleStyle == 2
fun initData(intent: Intent, finally: () -> Unit) {
execute {
url = intent.getStringExtra("url")
url?.let { url ->
rssSource = appDb.rssSourceDao.getByKey(url)
rssSource?.let {
titleLiveData.postValue(it.sourceName)
} ?: let {
rssSource = RssSource(sourceUrl = url)
}
}
}.onFinally {
finally()
}
}
fun switchLayout() {
rssSource?.let {
if (it.articleStyle < 2) {
it.articleStyle += 1
} else {
it.articleStyle = 0
}
execute {
appDb.rssSourceDao.update(it)
}
}
}
fun read(rssArticle: RssArticle) {
execute {
val rssReadRecord = RssReadRecord(
record = rssArticle.link,
title = rssArticle.title,
readTime = System.currentTimeMillis()
)
appDb.rssReadRecordDao.insertRecord(rssReadRecord)
}
}
fun clearArticles() {
execute {
url?.let {
appDb.rssArticleDao.delete(it)
}
order = System.currentTimeMillis()
}.onSuccess {
}
}
fun clearSortCache(onFinally: () -> Unit) {
execute {
rssSource?.removeSortCache()
}.onFinally {
onFinally.invoke()
}
}
fun getRecords(): List<RssReadRecord> {
return appDb.rssReadRecordDao.getRecords()
}
fun countRecords() : Int {
return appDb.rssReadRecordDao.countRecords
}
fun deleteAllRecord() {
execute {
appDb.rssReadRecordDao.deleteAllRecord()
}
}
}
@@ -0,0 +1,176 @@
@file:Suppress("DEPRECATION")
package io.legato.kazusa.ui.rss.favorites
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.lifecycle.lifecycleScope
import androidx.viewpager.widget.ViewPager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.databinding.ActivityRssFavoritesBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 收藏夹
*/
class RssFavoritesActivity : BaseActivity<ActivityRssFavoritesBinding>() {
override val binding by viewBinding(ActivityRssFavoritesBinding::inflate)
private val adapter by lazy { TabFragmentPageAdapter() }
private var groupList = mutableListOf<String>()
private var groupsMenu: SubMenu? = null
private var currentGroup = ""
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
upFragments()
}
override fun onResume() {
super.onResume()
//从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项
if (currentGroup.isNotEmpty() && groupList.isNotEmpty()){
var item = groupList.indexOf(currentGroup)
val currentItem = binding.viewPager.currentItem
//如果坐标没有变化,则结束
if(item == currentItem){
return
}
if (item == -1){
item = currentItem
}
lifecycleScope.launch {
delay(100)
binding.tabLayout.getTabAt(item)?.select()
}
}
}
private fun initView() {
binding.viewPager.adapter = adapter
binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
}
override fun onPageSelected(position: Int) {
currentGroup = groupList[position]
}
override fun onPageScrollStateChanged(state: Int) {}
})
binding.tabLayout.setupWithViewPager(binding.viewPager)
//binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_favorites, menu)
groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupsMenu()
return super.onCompatCreateOptionsMenu(menu)
}
private fun upGroupsMenu() = groupsMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEachIndexed { index, it ->
subMenu.add(R.id.menu_group, Menu.NONE, index, it)
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
if (item.groupId == R.id.menu_group) {
binding.viewPager.setCurrentItem(item.order)
} else {
when (item.itemId) {
R.id.menu_del_group -> deleteGroup()
R.id.menu_del_all -> deleteAll()
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun upFragments() {
lifecycleScope.launch {
appDb.rssStarDao.flowGroups().catch {
AppLog.put("订阅分组数据获取失败\n${it.localizedMessage}", it)
}.distinctUntilChanged().flowOn(IO).collect {
groupList.clear()
groupList.addAll(it)
if (groupList.size == 1) {
binding.tabLayout.gone()
} else {
binding.tabLayout.visible()
}
if (groupsMenu != null) {
upGroupsMenu()
}
adapter.notifyDataSetChanged()
}
}
}
private fun deleteGroup() {
alert(R.string.draw) {
val item = binding.viewPager.currentItem
val group = groupList[item]
setMessage(getString(R.string.sure_del) + "\n<" + group + ">" + getString(R.string.group))
noButton()
yesButton {
appDb.rssStarDao.deleteByGroup(group)
}
}
}
private fun deleteAll() {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n<" + getString(R.string.all) + ">" + getString(R.string.favorite))
noButton()
yesButton {
appDb.rssStarDao.deleteAll()
}
}
}
private inner class TabFragmentPageAdapter :
FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getItemPosition(`object`: Any): Int {
return POSITION_NONE
}
override fun getPageTitle(position: Int): CharSequence {
return groupList[position]
}
override fun getItem(position: Int): Fragment {
val group = groupList[position]
return RssFavoritesFragment(group)
}
override fun getCount(): Int {
return groupList.size
}
}
}
@@ -0,0 +1,91 @@
package io.legato.kazusa.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.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.RssStar
import io.legato.kazusa.databinding.ItemRssArticleBinding
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.help.glide.OkHttpModelLoader
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class RssFavoritesAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RssStar, ItemRssArticleBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemRssArticleBinding {
return ItemRssArticleBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssArticleBinding,
item: RssStar,
payloads: MutableList<Any>
) {
binding.run {
tvTitle.text = item.title
tvPubDate.text = item.pubDate
if (item.image.isNullOrBlank()) {
imageView.gone()
} else {
val options =
RequestOptions().set(OkHttpModelLoader.sourceOriginOption, item.origin)
ImageLoader.load(context, item.image)
.apply(options)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
imageView.gone()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
imageView.visible()
return false
}
})
.into(imageView)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssArticleBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.readRss(it)
}
}
holder.itemView.setOnLongClickListener {
getItem(holder.layoutPosition)?.let {
callBack.delStar(it)
}
true
}
}
interface CallBack {
fun readRss(rssStar: RssStar)
fun delStar(rssStar: RssStar)
}
}
@@ -0,0 +1,74 @@
package io.legato.kazusa.ui.rss.favorites
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.databinding.DialogRssFavoriteConfigBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class RssFavoritesDialog() : BaseDialogFragment(R.layout.dialog_rss_favorite_config, true) {
constructor(rssArticle: RssArticle) : this() {
arguments = Bundle().apply {
putString("title", rssArticle.title)
putString("group", rssArticle.group)
}
}
private val binding by viewBinding(DialogRssFavoriteConfigBinding::bind)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
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()
}
}
@@ -0,0 +1,86 @@
package io.legato.kazusa.ui.rss.favorites
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssStar
import io.legato.kazusa.databinding.FragmentRssArticlesBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.rss.read.ReadRssActivity
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
class RssFavoritesFragment() : VMBaseFragment<RssFavoritesViewModel>(R.layout.fragment_rss_articles),
RssFavoritesAdapter.CallBack {
constructor(group: String) : this() {
arguments = Bundle().apply {
putString("group", group)
}
}
private val binding by viewBinding(FragmentRssArticlesBinding::bind)
override val viewModel by viewModels<RssFavoritesViewModel>()
private val adapter: RssFavoritesAdapter by lazy {
RssFavoritesAdapter(requireContext(), this@RssFavoritesFragment)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
loadArticles()
}
private fun initView() = binding.run {
refreshLayout.isEnabled = false
// recyclerView.setEdgeEffectColor(primaryColor)
recyclerView.layoutManager = run {
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
LinearLayoutManager(requireContext())
}
recyclerView.adapter = adapter
recyclerView.applyNavigationBarPadding()
}
private fun loadArticles() {
lifecycleScope.launch {
val group = arguments?.getString("group") ?: "默认分组"
appDb.rssStarDao.flowByGroup(group).catch {
AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
override fun readRss(rssStar: RssStar) {
startActivity<ReadRssActivity> {
putExtra("title", rssStar.title)
putExtra("origin", rssStar.origin)
putExtra("link", rssStar.link)
}
}
override fun delStar(rssStar: RssStar) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n<" + rssStar.title + ">")
noButton()
yesButton {
appDb.rssStarDao.delete(rssStar.origin, rssStar.link)
}
}
}
}
@@ -0,0 +1,9 @@
package io.legato.kazusa.ui.rss.favorites
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
class RssFavoritesViewModel(application: Application) : BaseViewModel(application) {
}
@@ -0,0 +1,526 @@
package io.legato.kazusa.ui.rss.read
import android.annotation.SuppressLint
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.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.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.size
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppConst.imagePathKey
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.databinding.ActivityRssReadBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.http.CookieManager
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.model.Download
import io.legato.kazusa.ui.association.OnLineImportActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.rss.favorites.RssFavoritesDialog
import io.legato.kazusa.utils.ACache
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.isTrue
import io.legato.kazusa.utils.keepScreenOn
import io.legato.kazusa.utils.longSnackbar
import io.legato.kazusa.utils.openUrl
import io.legato.kazusa.utils.setDarkeningAllowed
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
import io.legato.kazusa.utils.share
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.splitNotBlank
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.textArray
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.toggleSystemBar
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
import splitties.views.bottomPadding
import java.io.ByteArrayInputStream
import java.net.URLDecoder
import java.util.regex.PatternSyntaxException
/**
* rss阅读界面
*/
class ReadRssActivity : VMBaseActivity<ActivityRssReadBinding, ReadRssViewModel>(),
RssFavoritesDialog.Callback {
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 customWebViewCallback: WebChromeClient.CustomViewCallback? = null
private val selectImageDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
ACache.get().put(imagePathKey, uri.toString())
viewModel.saveImage(it.value, uri)
}
}
private val rssJsExtensions by lazy { RssJsExtensions(this) }
fun getSource(): RssSource? {
return viewModel.rssSource
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
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 (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)
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_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 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"),
SelectItem(getString(R.string.select_folder), "selectFolder")
)
) { _, charSequence, _ ->
when (charSequence.value) {
"save" -> saveImage(webPic)
"selectFolder" -> selectSaveFolder(null)
}
}
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) {
val path = ACache.get().getAsString(imagePathKey)
if (path.isNullOrEmpty()) {
selectSaveFolder(webPic)
} else {
viewModel.saveImage(webPic, Uri.parse(path))
}
}
private fun selectSaveFolder(webPic: String?) {
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(imagePathKey)
if (!path.isNullOrEmpty()) {
default.add(SelectItem(path, -1))
}
selectImageDir.launch {
otherActions = default
value = webPic
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun initLiveData() {
viewModel.contentLiveData.observe(this) { content ->
viewModel.rssArticle?.let {
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
}
}
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.setDurProgress(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() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
return shouldOverrideUrlLoading(request.url)
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrlLoading(Uri.parse(url))
}
/**
* 如果有黑名单,黑名单匹配返回空白,
* 没有黑名单再判断白名单,在白名单中的才通过,
* 都没有不做处理
*/
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
val source = viewModel.rssSource ?: return super.shouldInterceptRequest(view, request)
val blacklist = source.contentBlacklist?.splitNotBlank(",")
if (!blacklist.isNullOrEmpty()) {
blacklist.forEach {
try {
if (url.startsWith(it) || url.matches(it.toRegex())) {
return createEmptyResource()
}
} catch (e: PatternSyntaxException) {
AppLog.put("黑名单规则正则语法错误 源名称:${source.sourceName} 正则:$it", e)
}
}
} else {
val whitelist = source.contentWhitelist?.splitNotBlank(",")
if (!whitelist.isNullOrEmpty()) {
whitelist.forEach {
try {
if (url.startsWith(it) || url.matches(it.toRegex())) {
return super.shouldInterceptRequest(view, request)
}
} catch (e: PatternSyntaxException) {
val msg = "白名单规则正则语法错误 源名称:${source.sourceName} 正则:$it"
AppLog.put(msg, e)
}
}
return createEmptyResource()
}
}
return super.shouldInterceptRequest(view, request)
}
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)
}
}
}
private fun createEmptyResource(): WebResourceResponse {
return WebResourceResponse(
"text/plain",
"utf-8",
ByteArrayInputStream("".toByteArray())
)
}
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
}
}
}
@SuppressLint("WebViewClientOnReceivedSslError")
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed()
}
}
}
@@ -0,0 +1,247 @@
package io.legato.kazusa.ui.rss.read
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.util.Base64
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.script.rhino.runScriptWithContext
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssArticle
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.data.entities.RssStar
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.TTS
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.rss.Rss
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeBytes
import kotlinx.coroutines.Dispatchers.IO
import splitties.init.appCtx
import java.util.Date
import kotlin.coroutines.coroutineContext
class ReadRssViewModel(application: Application) : BaseViewModel(application) {
var rssSource: RssSource? = null
var rssArticle: RssArticle? = null
var tts: TTS? = null
val contentLiveData = MutableLiveData<String>()
val urlLiveData = MutableLiveData<AnalyzeUrl>()
var rssStar: RssStar? = null
val upTtsMenuData = MutableLiveData<Boolean>()
val upStarMenuData = MutableLiveData<Boolean>()
var headerMap: Map<String, String> = emptyMap()
fun initData(intent: Intent) {
execute {
val origin = intent.getStringExtra("origin") ?: return@execute
val link = intent.getStringExtra("link")
rssSource = appDb.rssSourceDao.getByKey(origin)
headerMap = runScriptWithContext {
rssSource?.getHeaderMap() ?: emptyMap()
}
if (link != null) {
rssStar = appDb.rssStarDao.get(origin, link)
rssArticle = rssStar?.toRssArticle() ?: appDb.rssArticleDao.get(origin, link)
val rssArticle = rssArticle ?: return@execute
if (!rssArticle.description.isNullOrBlank()) {
contentLiveData.postValue(rssArticle.description!!)
} else {
rssSource?.let {
val ruleContent = it.ruleContent
if (!ruleContent.isNullOrBlank()) {
loadContent(rssArticle, ruleContent)
} else {
loadUrl(rssArticle.link, rssArticle.origin)
}
} ?: loadUrl(rssArticle.link, rssArticle.origin)
}
} else {
val ruleContent = rssSource?.ruleContent
if (ruleContent.isNullOrBlank()) {
loadUrl(origin, origin)
} else {
val rssArticle = RssArticle()
rssArticle.origin = origin
rssArticle.link = origin
rssArticle.title = rssSource!!.sourceName
loadContent(rssArticle, ruleContent)
}
}
}.onFinally {
upStarMenuData.postValue(true)
}
}
private suspend fun loadUrl(url: String, baseUrl: String) {
val analyzeUrl = AnalyzeUrl(
mUrl = url,
baseUrl = baseUrl,
source = rssSource,
coroutineContext = coroutineContext,
hasLoginHeader = false
)
urlLiveData.postValue(analyzeUrl)
}
private fun loadContent(rssArticle: RssArticle, ruleContent: String) {
val source = rssSource ?: return
Rss.getContent(viewModelScope, rssArticle, ruleContent, source)
.onSuccess(IO) { body ->
rssArticle.description = body
appDb.rssArticleDao.insert(rssArticle)
rssStar?.let {
it.description = body
appDb.rssStarDao.insert(it)
}
contentLiveData.postValue(body)
}.onError {
contentLiveData.postValue("加载正文失败\n${it.stackTraceToString()}")
}
}
fun refresh(finish: () -> Unit) {
rssArticle?.let { rssArticle ->
rssSource?.let {
val ruleContent = it.ruleContent
if (!ruleContent.isNullOrBlank()) {
loadContent(rssArticle, ruleContent)
} else {
finish.invoke()
}
} ?: let {
appCtx.toastOnUi("订阅源不存在")
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() {
execute {
rssStar ?: rssArticle?.toStar()?.let {
appDb.rssStarDao.insert(it)
rssStar = it
}
}.onSuccess {
upStarMenuData.postValue(true)
}
}
fun updateFavorite() {
execute {
rssArticle?.toStar()?.let {
appDb.rssStarDao.update(it)
rssStar = it
}
}.onSuccess {
upStarMenuData.postValue(true)
}
}
fun delFavorite() {
execute {
rssStar?.let {
appDb.rssStarDao.delete(it.origin, it.link)
rssStar = null
}
}.onSuccess {
upStarMenuData.postValue(true)
}
}
fun saveImage(webPic: String?, uri: Uri) {
webPic ?: return
execute {
val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg"
val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL")
uri.writeBytes(context, fileName, byteArray)
}.onError {
context.toastOnUi("保存图片失败:${it.localizedMessage}")
}.onSuccess {
context.toastOnUi("保存成功")
}
}
private suspend fun webData2bitmap(data: String): ByteArray? {
return if (URLUtil.isValidUrl(data)) {
okHttpClient.newCallResponseBody {
url(data)
}.bytes()
} else {
Base64.decode(data.split(",").toTypedArray()[1], Base64.DEFAULT)
}
}
fun clHtml(content: String): String {
return when {
!rssSource?.style.isNullOrEmpty() -> {
"""
<style>
${rssSource?.style}
</style>
$content
""".trimIndent()
}
content.contains("<style>".toRegex()) -> {
content
}
else -> {
"""
<style>
img{max-width:100% !important; width:auto; height:auto;}
video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}
body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}
</style>
$content
""".trimIndent()
}
}
}
@Synchronized
fun readAloud(text: String) {
if (tts == null) {
tts = TTS().apply {
setSpeakStateListener(object : TTS.SpeakStateListener {
override fun onStart() {
upTtsMenuData.postValue(true)
}
override fun onDone() {
upTtsMenuData.postValue(false)
}
})
}
}
tts?.speak(text)
}
override fun onCleared() {
super.onCleared()
tts?.clearTts()
}
}
@@ -0,0 +1,24 @@
package io.legato.kazusa.ui.rss.read
import io.legato.kazusa.data.entities.BaseSource
import io.legato.kazusa.help.JsExtensions
import io.legato.kazusa.ui.association.AddToBookshelfDialog
import io.legato.kazusa.ui.book.search.SearchActivity
import io.legato.kazusa.utils.showDialogFragment
@Suppress("unused")
class RssJsExtensions(private val activity: ReadRssActivity) : JsExtensions {
override fun getSource(): BaseSource? {
return activity.getSource()
}
fun searchBook(key: String) {
SearchActivity.start(activity, key)
}
fun addBook(bookUrl: String) {
activity.showDialogFragment(AddToBookshelfDialog(bookUrl))
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.rss.read
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.webkit.WebView
class VisibleWebView(
context: Context,
attrs: AttributeSet? = null
) : WebView(context, attrs) {
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(View.VISIBLE)
}
}
@@ -0,0 +1,77 @@
package io.legato.kazusa.ui.rss.source.debug
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.SearchView
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.databinding.ActivitySourceDebugBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.TextDialog
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.launch
class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() {
override val binding by viewBinding(ActivitySourceDebugBinding::inflate)
override val viewModel by viewModels<RssSourceDebugModel>()
private val adapter by lazy { RssSourceDebugAdapter(this) }
override fun onActivityCreated(savedInstanceState: Bundle?) {
initRecyclerView()
initSearchView()
viewModel.observe { state, msg ->
lifecycleScope.launch {
adapter.addItem(msg)
if (state == -1 || state == 1000) {
binding.rotateLoading.gone()
}
}
}
viewModel.initData(intent.getStringExtra("key")) {
startDebug()
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_source_debug, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_list_src -> showDialogFragment(TextDialog("Html", viewModel.listSrc))
R.id.menu_content_src -> showDialogFragment(TextDialog("Html", viewModel.contentSrc))
}
return super.onCompatOptionsItemSelected(item)
}
private fun initRecyclerView() {
// binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
// binding.rotateLoading.loadingColor = accentColor
}
private fun initSearchView() {
binding.titleBar.findViewById<SearchView>(R.id.search_view).gone()
}
private fun startDebug() {
adapter.clearItems()
viewModel.rssSource?.let {
binding.rotateLoading.visible()
viewModel.startDebug(it)
} ?: toastOnUi(R.string.error_no_source)
}
}
@@ -0,0 +1,44 @@
package io.legato.kazusa.ui.rss.source.debug
import android.content.Context
import android.view.View
import android.view.ViewGroup
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.databinding.ItemLogBinding
class RssSourceDebugAdapter(context: Context) :
RecyclerAdapter<String, ItemLogBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
return ItemLogBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemLogBinding,
item: String,
payloads: MutableList<Any>
) {
binding.apply {
if (textView.getTag(R.id.tag1) == null) {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
textView.isCursorVisible = false
textView.isCursorVisible = true
}
override fun onViewDetachedFromWindow(v: View) {}
}
textView.addOnAttachStateChangeListener(listener)
textView.setTag(R.id.tag1, listener)
}
textView.text = item
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
//nothing
}
}
@@ -0,0 +1,50 @@
package io.legato.kazusa.ui.rss.source.debug
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.model.Debug
class RssSourceDebugModel(application: Application) : BaseViewModel(application),
Debug.Callback {
var rssSource: RssSource? = null
private var callback: ((Int, String) -> Unit)? = null
var listSrc: String? = null
var contentSrc: String? = null
fun initData(sourceUrl: String?, finally: () -> Unit) {
sourceUrl?.let {
execute {
rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
}.onFinally {
finally()
}
}
}
fun observe(callback: (Int, String) -> Unit) {
this.callback = callback
}
fun startDebug(source: RssSource) {
execute {
Debug.callback = this@RssSourceDebugModel
Debug.startDebug(this, source)
}
}
override fun printLog(state: Int, msg: String) {
when (state) {
10 -> listSrc = msg
20 -> contentSrc = msg
else -> callback?.invoke(state, msg)
}
}
override fun onCleared() {
super.onCleared()
Debug.cancelDebug(true)
}
}
@@ -0,0 +1,404 @@
package io.legato.kazusa.ui.rss.source.edit
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.tabs.TabLayout
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.databinding.ActivityRssSourceEditBinding
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.qrcode.QrCodeResult
import io.legato.kazusa.ui.rss.source.debug.RssSourceDebugActivity
import io.legato.kazusa.ui.widget.dialog.UrlOptionDialog
import io.legato.kazusa.ui.widget.dialog.VariableDialog
import io.legato.kazusa.ui.widget.keyboard.KeyboardToolPop
import io.legato.kazusa.ui.widget.text.EditEntity
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.imeHeight
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.isTrue
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.navigationBarHeight
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
import io.legato.kazusa.utils.share
import io.legato.kazusa.utils.shareWithQr
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.showHelp
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.views.bottomPadding
class RssSourceEditActivity :
VMBaseActivity<ActivityRssSourceEditBinding, RssSourceEditViewModel>(),
KeyboardToolPop.CallBack,
VariableDialog.Callback {
override val binding by viewBinding(ActivityRssSourceEditBinding::inflate)
override val viewModel by viewModels<RssSourceEditViewModel>()
private val softKeyboardTool by lazy {
KeyboardToolPop(this, lifecycleScope, binding.root, this)
}
private val adapter by lazy { RssSourceEditAdapter() }
private val sourceEntities: ArrayList<EditEntity> = ArrayList()
private val listEntities: ArrayList<EditEntity> = ArrayList()
private val webViewEntities: ArrayList<EditEntity> = ArrayList()
private val selectDoc = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
if (uri.isContentScheme()) {
sendText(uri.toString())
} else {
sendText(uri.path.toString())
}
}
}
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
it?.let {
viewModel.importSource(it) { source: RssSource ->
upSourceView(source)
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
softKeyboardTool.attachToWindow(window)
initView()
viewModel.initData(intent) {
upSourceView(viewModel.rssSource)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
if (!LocalConfig.ruleHelpVersionIsLast) {
showHelp("ruleHelp")
}
}
override fun finish() {
val source = getRssSource()
if (!source.equal(viewModel.rssSource ?: RssSource())) {
alert(R.string.exit) {
setMessage(R.string.exit_no_save)
positiveButton(R.string.yes)
negativeButton(R.string.no) {
super.finish()
}
}
} else {
super.finish()
}
}
override fun onDestroy() {
super.onDestroy()
softKeyboardTool.dismiss()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.source_edit, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank()
menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_save -> viewModel.save(getRssSource()) {
setResult(RESULT_OK)
finish()
}
R.id.menu_debug_source -> viewModel.save(getRssSource()) { source ->
startActivity<RssSourceDebugActivity> {
putExtra("key", source.sourceUrl)
}
}
R.id.menu_login -> viewModel.save(getRssSource()) {
startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", it.sourceUrl)
}
}
R.id.menu_set_source_variable -> setSourceVariable()
R.id.menu_clear_cookie -> viewModel.clearCookie(getRssSource().sourceUrl)
R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete
R.id.menu_copy_source -> sendToClip(GSON.toJson(getRssSource()))
R.id.menu_qr_code_camera -> qrCodeResult.launch()
R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) }
R.id.menu_share_str -> share(GSON.toJson(getRssSource()))
R.id.menu_share_qr -> shareWithQr(
GSON.toJson(getRssSource()),
getString(R.string.share_rss_source),
ErrorCorrectionLevel.L
)
R.id.menu_help -> showHelp("ruleHelp")
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_base)
})
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_list)
})
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
text = "WEB_VIEW"
})
// binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.adapter = adapter
// binding.tabLayout.setBackgroundColor(backgroundColor)
// binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabReselected(tab: TabLayout.Tab?) {
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
}
override fun onTabSelected(tab: TabLayout.Tab?) {
setEditEntities(tab?.position)
}
})
binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
val navigationBarHeight = windowInsets.navigationBarHeight
val imeHeight = windowInsets.imeHeight
view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0
softKeyboardTool.initialPadding = imeHeight
windowInsets
}
}
private fun setEditEntities(tabPosition: Int?) {
when (tabPosition) {
1 -> adapter.editEntities = listEntities
2 -> adapter.editEntities = webViewEntities
else -> adapter.editEntities = sourceEntities
}
binding.recyclerView.scrollToPosition(0)
}
private fun upSourceView(rssSource: RssSource?) {
val rs = rssSource ?: RssSource()
rs.let {
binding.cbIsEnable.isChecked = rs.enabled
binding.cbSingleUrl.isChecked = rs.singleUrl
binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true
}
sourceEntities.clear()
sourceEntities.apply {
add(EditEntity("sourceName", rs.sourceName, R.string.source_name))
add(EditEntity("sourceUrl", rs.sourceUrl, R.string.source_url))
add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon))
add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group))
add(EditEntity("sourceComment", rs.sourceComment, R.string.comment))
add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url))
add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url))
add(EditEntity("loginUi", rs.loginUi, R.string.login_ui))
add(EditEntity("loginCheckJs", rs.loginCheckJs, R.string.login_check_js))
add(EditEntity("coverDecodeJs", rs.coverDecodeJs, R.string.cover_decode_js))
add(EditEntity("header", rs.header, R.string.source_http_header))
add(EditEntity("variableComment", rs.variableComment, R.string.variable_comment))
add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate))
add(EditEntity("jsLib", rs.jsLib, "jsLib"))
}
listEntities.clear()
listEntities.apply {
add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles))
add(EditEntity("ruleNextPage", rs.ruleNextPage, R.string.r_next))
add(EditEntity("ruleTitle", rs.ruleTitle, R.string.r_title))
add(EditEntity("rulePubDate", rs.rulePubDate, R.string.r_date))
add(EditEntity("ruleDescription", rs.ruleDescription, R.string.r_description))
add(EditEntity("ruleImage", rs.ruleImage, R.string.r_image))
add(EditEntity("ruleLink", rs.ruleLink, R.string.r_link))
}
webViewEntities.clear()
webViewEntities.apply {
add(
EditEntity(
"enableJs",
rs.enableJs.toString(),
R.string.enable_js,
EditEntity.ViewType.checkBox
)
)
add(
EditEntity(
"loadWithBaseUrl",
rs.loadWithBaseUrl.toString(),
R.string.load_with_base_url,
EditEntity.ViewType.checkBox
)
)
add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content))
add(EditEntity("style", rs.style, R.string.r_style))
add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js))
add(EditEntity("contentWhitelist", rs.contentWhitelist, R.string.c_whitelist))
add(EditEntity("contentBlacklist", rs.contentBlacklist, R.string.c_blacklist))
add(
EditEntity(
"shouldOverrideUrlLoading",
rs.shouldOverrideUrlLoading,
"url跳转拦截(js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入)"
)
)
}
binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0))
setEditEntities(0)
}
private fun getRssSource(): RssSource {
val source = viewModel.rssSource?.copy() ?: RssSource()
source.enabled = binding.cbIsEnable.isChecked
source.singleUrl = binding.cbSingleUrl.isChecked
source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
sourceEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) {
"sourceName" -> source.sourceName = it.value ?: ""
"sourceUrl" -> source.sourceUrl = it.value ?: ""
"sourceIcon" -> source.sourceIcon = it.value ?: ""
"sourceGroup" -> source.sourceGroup = it.value
"sourceComment" -> source.sourceComment = it.value
"loginUrl" -> source.loginUrl = it.value
"loginUi" -> source.loginUi = it.value
"loginCheckJs" -> source.loginCheckJs = it.value
"coverDecodeJs" -> source.coverDecodeJs = it.value
"header" -> source.header = it.value
"variableComment" -> source.variableComment = it.value
"concurrentRate" -> source.concurrentRate = it.value
"sortUrl" -> source.sortUrl = it.value
"jsLib" -> source.jsLib = it.value
}
}
listEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) {
"ruleArticles" -> source.ruleArticles = it.value
"ruleNextPage" -> source.ruleNextPage =
viewModel.ruleComplete(it.value, source.ruleArticles, 2)
"ruleTitle" -> source.ruleTitle =
viewModel.ruleComplete(it.value, source.ruleArticles)
"rulePubDate" -> source.rulePubDate =
viewModel.ruleComplete(it.value, source.ruleArticles)
"ruleDescription" -> source.ruleDescription =
viewModel.ruleComplete(it.value, source.ruleArticles)
"ruleImage" -> source.ruleImage =
viewModel.ruleComplete(it.value, source.ruleArticles, 3)
"ruleLink" -> source.ruleLink =
viewModel.ruleComplete(it.value, source.ruleArticles)
}
}
webViewEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) {
"enableJs" -> source.enableJs = it.value.isTrue()
"loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue()
"ruleContent" -> source.ruleContent =
viewModel.ruleComplete(it.value, source.ruleArticles)
"style" -> source.style = it.value
"injectJs" -> source.injectJs = it.value
"contentWhitelist" -> source.contentWhitelist = it.value
"contentBlacklist" -> source.contentBlacklist = it.value
"shouldOverrideUrlLoading" -> source.shouldOverrideUrlLoading = it.value
}
}
return source
}
private fun setSourceVariable() {
viewModel.save(getRssSource()) { source ->
lifecycleScope.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)
}
override fun helpActions(): List<SelectItem<String>> {
return arrayListOf(
SelectItem("插入URL参数", "urlOption"),
SelectItem("订阅源教程", "ruleHelp"),
SelectItem("js教程", "jsHelp"),
SelectItem("正则教程", "regexHelp"),
SelectItem("选择文件", "selectFile"),
)
}
override fun onHelpActionSelect(action: String) {
when (action) {
"urlOption" -> UrlOptionDialog(this) {
sendText(it)
}.show()
"ruleHelp" -> showHelp("ruleHelp")
"jsHelp" -> showHelp("jsHelp")
"regexHelp" -> showHelp("regexHelp")
"selectFile" -> selectDoc.launch {
mode = HandleFileContract.FILE
}
}
}
override fun sendText(text: String) {
if (text.isBlank()) return
val view = window.decorView.findFocus()
if (view is EditText) {
val start = view.selectionStart
val end = view.selectionEnd
val edit = view.editableText//获取EditText的文字
if (start < 0 || start >= edit.length) {
edit.append(text)
} else {
edit.replace(start, end, text)//光标所在位置插入文字
}
}
}
}
@@ -0,0 +1,132 @@
package io.legato.kazusa.ui.rss.source.edit
import android.annotation.SuppressLint
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.databinding.ItemSourceEditBinding
import io.legato.kazusa.databinding.ItemSourceEditCheckBoxBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.ui.widget.code.addJsPattern
import io.legato.kazusa.ui.widget.code.addJsonPattern
import io.legato.kazusa.ui.widget.code.addLegadoPattern
import io.legato.kazusa.ui.widget.text.EditEntity
import io.legato.kazusa.utils.isTrue
class RssSourceEditAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val editEntityMaxLine = AppConfig.sourceEditMaxLine
var editEntities: ArrayList<EditEntity> = ArrayList()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int {
val item = editEntities[position]
return item.viewType
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
EditEntity.ViewType.checkBox -> {
val binding = ItemSourceEditCheckBoxBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
CheckBoxViewHolder(binding)
}
else -> {
val binding = ItemSourceEditBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
binding.editText.addLegadoPattern()
binding.editText.addJsonPattern()
binding.editText.addJsPattern()
EditTextViewHolder(binding)
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is CheckBoxViewHolder -> holder.bind(editEntities[position])
is EditTextViewHolder -> holder.bind(editEntities[position])
}
}
override fun getItemCount(): Int {
return editEntities.size
}
inner class EditTextViewHolder(val binding: ItemSourceEditBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(editEntity: EditEntity) = binding.run {
editText.maxLines = editEntityMaxLine
if (editText.getTag(R.id.tag1) == null) {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
editText.isCursorVisible = false
editText.isCursorVisible = true
editText.isFocusable = true
editText.isFocusableInTouchMode = true
}
override fun onViewDetachedFromWindow(v: View) {
}
}
editText.addOnAttachStateChangeListener(listener)
editText.setTag(R.id.tag1, listener)
}
editText.getTag(R.id.tag2)?.let {
if (it is TextWatcher) {
editText.removeTextChangedListener(it)
}
}
editText.setText(editEntity.value)
textInputLayout.hint = editEntity.hint
val textWatcher = object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence,
start: Int,
count: Int,
after: Int
) {
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: Editable?) {
editEntity.value = (s?.toString())
}
}
editText.addTextChangedListener(textWatcher)
editText.setTag(R.id.tag2, textWatcher)
}
}
inner class CheckBoxViewHolder(val binding: ItemSourceEditCheckBoxBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(editEntity: EditEntity) = binding.run {
checkBox.setOnCheckedChangeListener(null)
checkBox.text = editEntity.hint
checkBox.isChecked = editEntity.value.isTrue()
checkBox.setOnCheckedChangeListener { _, isChecked ->
editEntity.value = isChecked.toString()
}
}
}
}
@@ -0,0 +1,107 @@
package io.legato.kazusa.ui.rss.source.edit
import android.app.Application
import android.content.Intent
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppCacheManager
import io.legato.kazusa.help.RuleComplete
import io.legato.kazusa.help.http.CookieStore
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.getClipText
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
class RssSourceEditViewModel(application: Application) : BaseViewModel(application) {
var autoComplete = false
var rssSource: RssSource? = null
fun initData(intent: Intent, onFinally: () -> Unit) {
execute {
val key = intent.getStringExtra("sourceUrl")
if (key != null) {
appDb.rssSourceDao.getByKey(key)?.let {
rssSource = it
}
}
}.onFinally {
onFinally()
}
}
fun save(source: RssSource, success: ((RssSource) -> Unit)) {
execute {
if (source.sourceName.isBlank() || source.sourceName.isBlank()) {
throw NoStackTraceException(context.getString(R.string.non_null_name_url))
}
rssSource?.let {
appDb.rssSourceDao.delete(it)
//更新收藏的源地址
if (it.sourceUrl != source.sourceUrl) {
appDb.rssStarDao.updateOrigin(source.sourceUrl, it.sourceUrl)
appDb.rssArticleDao.updateOrigin(source.sourceUrl, it.sourceUrl)
appDb.cacheDao.deleteSourceVariables(it.sourceUrl)
AppCacheManager.clearSourceVariables()
}
}
appDb.rssSourceDao.insert(source)
rssSource = source
source
}.onSuccess {
success(it)
}.onError {
context.toastOnUi(it.localizedMessage)
it.printOnDebug()
}
}
fun pasteSource(onSuccess: (source: RssSource) -> Unit) {
execute(context = Dispatchers.Main) {
var source: RssSource? = null
context.getClipText()?.let { json ->
source = GSON.fromJsonObject<RssSource>(json).getOrThrow()
}
source
}.onError {
context.toastOnUi(it.localizedMessage)
}.onSuccess {
if (it != null) {
onSuccess(it)
} else {
context.toastOnUi("格式不对")
}
}
}
fun importSource(text: String, finally: (source: RssSource) -> Unit) {
execute {
val text1 = text.trim()
GSON.fromJsonObject<RssSource>(text1).getOrThrow().let {
finally.invoke(it)
}
}.onError {
context.toastOnUi(it.stackTraceStr)
}
}
fun clearCookie(url: String) {
execute {
CookieStore.removeCookie(url)
}
}
fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? {
if (autoComplete) {
return RuleComplete.autoComplete(rule, preRule, type)
}
return rule
}
}
@@ -0,0 +1,147 @@
package io.legato.kazusa.ui.rss.source.manage
import android.annotation.SuppressLint
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.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.appDb
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemGroupManageBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.requestInputMethod
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class GroupManageDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel: RssSourceViewModel by activityViewModels()
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy { GroupAdapter(requireContext()) }
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.title = getString(R.string.group_manage)
toolBar.inflateMenu(R.menu.group_manage)
toolBar.menu.applyTint(requireContext())
toolBar.setOnMenuItemClickListener(this@GroupManageDialog)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
recyclerView.adapter = adapter
//tvOk.setTextColor(requireContext().accentColor)
tvOk.visible()
tvOk.setOnClickListener {
dismissAllowingStateLoss()
}
initData()
}
private fun initData() {
lifecycleScope.launch {
appDb.rssSourceDao.flowGroups().conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> addGroup()
}
return true
}
@SuppressLint("InflateParams")
private fun addGroup() {
alert(title = getString(R.string.add_group)) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotBlank()) {
viewModel.addGroup(it)
}
}
}
cancelButton()
}.requestInputMethod()
}
@SuppressLint("InflateParams")
private fun editGroup(group: String) {
alert(title = getString(R.string.group_edit)) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setText(group)
}
customView { alertBinding.root }
okButton {
viewModel.upGroup(group, alertBinding.editView.text?.toString())
}
cancelButton()
}.requestInputMethod()
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<String, ItemGroupManageBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
return ItemGroupManageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemGroupManageBinding,
item: String,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
tvGroup.text = item
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
binding.apply {
tvEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let {
editGroup(it)
}
}
tvDel.setOnClickListener {
getItem(holder.layoutPosition)?.let {
viewModel.delGroup(it)
}
}
}
}
}
}
@@ -0,0 +1,439 @@
package io.legato.kazusa.ui.rss.source.manage
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.SubMenu
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.databinding.ActivityRssSourceBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.DirectLinkUpload
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.ui.association.ImportRssSourceDialog
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.qrcode.QrCodeResult
import io.legato.kazusa.ui.rss.source.edit.RssSourceEditActivity
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.ui.widget.recycler.DragSelectTouchHelper
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.ACache
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.readText
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.share
import io.legato.kazusa.utils.shouldHideSoftInput
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.showHelp
import io.legato.kazusa.utils.splitNotBlank
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.transaction
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 订阅源管理
*/
class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
RssSourceAdapter.CallBack {
override val binding by viewBinding(ActivityRssSourceBinding::inflate)
override val viewModel by viewModels<RssSourceViewModel>()
private val importRecordKey = "rssSourceRecordKey"
private val adapter by lazy { RssSourceAdapter(this, this) }
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var sourceFlowJob: Job? = null
private var groups = hashSetOf<String>()
private var groupMenu: SubMenu? = null
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
it ?: return@registerForActivityResult
showDialogFragment(
ImportRssSourceDialog(it)
)
}
private val importDoc = registerForActivityResult(HandleFileContract()) {
kotlin.runCatching {
it.uri?.readText(this)?.let {
showDialogFragment(
ImportRssSourceDialog(it)
)
}
}.onFailure {
toastOnUi("readTextError:${it.localizedMessage}")
}
}
private val exportResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
sendToClip(uri.toString())
}
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
initRecyclerView()
initSearchView()
initGroupFlow()
upSourceFlow()
initSelectActionBar()
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
currentFocus?.let {
if (it.shouldHideSoftInput(ev)) {
it.post {
it.clearFocus()
it.hideSoftInput()
}
}
}
}
return super.dispatchTouchEvent(ev)
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_source, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
groupMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> startActivity<RssSourceEditActivity>()
R.id.menu_import_local -> importDoc.launch {
mode = HandleFileContract.FILE
allowExtensions = arrayOf("txt", "json")
}
R.id.menu_import_onLine -> showImportDialog()
R.id.menu_import_qr -> qrCodeResult.launch()
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_import_default -> viewModel.importDefault()
R.id.menu_enabled_group -> {
searchView.setQuery(getString(R.string.enabled), true)
}
R.id.menu_disabled_group -> {
searchView.setQuery(getString(R.string.disabled), true)
}
R.id.menu_group_login -> {
searchView.setQuery(getString(R.string.need_login), true)
}
R.id.menu_group_null -> {
searchView.setQuery(getString(R.string.no_group), true)
}
R.id.menu_help -> showHelp("SourceMRssHelp")
else -> if (item.groupId == R.id.source_group) {
searchView.setQuery("group:${item.title}", true)
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
R.id.menu_add_group -> selectionAddToGroups()
R.id.menu_remove_group -> selectionRemoveFromGroups()
R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray())
R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray())
R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file ->
exportResult.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"exportRssSource.json", file, "application/json"
)
}
}
R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) {
share(it)
}
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
}
return true
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
// When this page is opened, it is in selection mode
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initSearchView() {
binding.titleBar.findViewById<SearchView>(R.id.search_view).let {
//it.applyTint(primaryTextColor)
it.onActionViewExpanded()
it.queryHint = getString(R.string.search_rss_source)
it.clearFocus()
it.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
upSourceFlow(newText)
return false
}
})
}
}
private fun initSelectActionBar() {
binding.selectActionBar.setMainActionText(R.string.delete)
binding.selectActionBar.inflateMenu(R.menu.rss_source_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun initGroupFlow() {
lifecycleScope.launch {
appDb.rssSourceDao.flowGroups().conflate().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
@SuppressLint("InflateParams")
private fun selectionAddToGroups() {
alert(titleResource = R.string.add_group) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotEmpty()) {
viewModel.selectionAddToGroups(adapter.selection, it)
}
}
}
cancelButton()
}
}
@SuppressLint("InflateParams")
private fun selectionRemoveFromGroups() {
alert(titleResource = R.string.remove_group) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotEmpty()) {
viewModel.selectionRemoveFromGroups(adapter.selection, it)
}
}
}
cancelButton()
}
}
override fun selectAll(selectAll: Boolean) {
if (selectAll) {
adapter.selectAll()
} else {
adapter.revertSelection()
}
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
delSourceDialog()
}
private fun delSourceDialog() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
yesButton { viewModel.del(*adapter.selection.toTypedArray()) }
noButton()
}
}
private fun upGroupMenu() = groupMenu?.transaction { menu ->
menu.removeGroup(R.id.source_group)
groups.forEach {
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it)
}
}
private fun upSourceFlow(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = lifecycleScope.launch {
when {
searchKey.isNullOrBlank() -> {
appDb.rssSourceDao.flowAll()
}
searchKey == getString(R.string.enabled) -> {
appDb.rssSourceDao.flowEnabled()
}
searchKey == getString(R.string.disabled) -> {
appDb.rssSourceDao.flowDisabled()
}
searchKey == getString(R.string.need_login) -> {
appDb.rssSourceDao.flowLogin()
}
searchKey == getString(R.string.no_group) -> {
appDb.rssSourceDao.flowNoGroup()
}
searchKey.startsWith("group:") -> {
val key = searchKey.substringAfter("group:")
appDb.rssSourceDao.flowGroupSearch(key)
}
else -> {
appDb.rssSourceDao.flowSearch(searchKey)
}
}.catch {
AppLog.put("订阅源管理界面更新数据出错", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it, adapter.diffItemCallback)
delay(100)
}
}
}
override fun upCountView() {
binding.selectActionBar.upCountView(
adapter.selection.size,
adapter.itemCount
)
}
@SuppressLint("InflateParams")
private fun showImportDialog() {
val aCache = ACache.get(cacheDir = false)
val cacheUrls: MutableList<String> = aCache
.getAsString(importRecordKey)
?.splitNotBlank(",")
?.toMutableList() ?: mutableListOf()
alert(titleResource = R.string.import_on_line) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url"
editView.setFilterValues(cacheUrls)
editView.delCallBack = {
cacheUrls.remove(it)
aCache.put(importRecordKey, cacheUrls.joinToString(","))
}
}
customView { alertBinding.root }
okButton {
val text = alertBinding.editView.text?.toString()
text?.let {
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
cacheUrls.add(0, it)
aCache.put(importRecordKey, cacheUrls.joinToString(","))
}
showDialogFragment(
ImportRssSourceDialog(it)
)
}
}
cancelButton()
}
}
override fun del(source: RssSource) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + source.sourceName)
noButton()
yesButton {
viewModel.del(source)
}
}
}
override fun edit(source: RssSource) {
startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", source.sourceUrl)
}
}
override fun update(vararg source: RssSource) {
viewModel.update(*source)
}
override fun toTop(source: RssSource) {
viewModel.topSource(source)
}
override fun toBottom(source: RssSource) {
viewModel.bottomSource(source)
}
override fun upOrder() {
viewModel.upOrder()
}
}
@@ -0,0 +1,254 @@
package io.legato.kazusa.ui.rss.source.manage
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.databinding.ItemRssSourceBinding
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.ui.widget.recycler.DragSelectTouchHelper
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import java.util.Collections
class RssSourceAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RssSource, ItemRssSourceBinding>(context),
ItemTouchCallback.Callback {
private val selected = linkedSetOf<RssSource>()
val selection: List<RssSource>
get() {
return getItems().filter {
selected.contains(it)
}
}
val diffItemCallback = object : DiffUtil.ItemCallback<RssSource>() {
override fun areItemsTheSame(oldItem: RssSource, newItem: RssSource): Boolean {
return oldItem.sourceUrl == newItem.sourceUrl
}
override fun areContentsTheSame(oldItem: RssSource, newItem: RssSource): Boolean {
return oldItem.sourceName == newItem.sourceName
&& oldItem.sourceGroup == newItem.sourceGroup
&& oldItem.enabled == newItem.enabled
}
override fun getChangePayload(oldItem: RssSource, newItem: RssSource): Any? {
val payload = Bundle()
if (oldItem.sourceName != newItem.sourceName
|| oldItem.sourceGroup != newItem.sourceGroup
) {
payload.putBoolean("upName", true)
}
if (oldItem.enabled != newItem.enabled) {
payload.putBoolean("enabled", newItem.enabled)
}
if (payload.isEmpty) {
return null
}
return payload
}
}
override fun getViewBinding(parent: ViewGroup): ItemRssSourceBinding {
return ItemRssSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssSourceBinding,
item: RssSource,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
//root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f))
cbSource.text = item.getDisplayNameGroup()
swtEnabled.isChecked = item.enabled
cbSource.isChecked = selected.contains(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"upName" -> cbSource.text = item.getDisplayNameGroup()
"enabled" -> swtEnabled.isChecked = bundle.getBoolean("enabled")
"selected" -> cbSource.isChecked = selected.contains(item)
}
}
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssSourceBinding) {
binding.apply {
swtEnabled.setOnCheckedChangeListener { view, checked ->
if (view.isPressed) {
getItem(holder.layoutPosition)?.let {
if (view.isPressed) {
it.enabled = checked
callBack.update(it)
}
}
}
}
cbSource.setOnCheckedChangeListener { view, checked ->
if (view.isPressed) {
getItem(holder.layoutPosition)?.let {
if (view.isPressed) {
if (checked) {
selected.add(it)
} else {
selected.remove(it)
}
callBack.upCountView()
}
}
}
}
ivEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.edit(it)
}
}
ivMenuMore.setOnClickListener {
showMenu(ivMenuMore, holder.layoutPosition)
}
}
}
override fun onCurrentListChanged() {
callBack.upCountView()
}
fun selectAll() {
getItems().forEach {
selected.add(it)
}
notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
callBack.upCountView()
}
fun revertSelection() {
getItems().forEach {
if (selected.contains(it)) {
selected.remove(it)
} else {
selected.add(it)
}
}
notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
callBack.upCountView()
}
fun checkSelectedInterval() {
val selectedPosition = linkedSetOf<Int>()
getItems().forEachIndexed { index, it ->
if (selected.contains(it)) {
selectedPosition.add(index)
}
}
val minPosition = Collections.min(selectedPosition)
val maxPosition = Collections.max(selectedPosition)
val itemCount = maxPosition - minPosition + 1
for (i in minPosition..maxPosition) {
getItem(i)?.let {
selected.add(it)
}
}
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
callBack.upCountView()
}
private fun showMenu(view: View, position: Int) {
val source = getItem(position) ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.rss_source_item)
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menu_top -> callBack.toTop(source)
R.id.menu_bottom -> callBack.toBottom(source)
R.id.menu_del -> {
callBack.del(source)
selected.remove(source)
}
}
true
}
popupMenu.show()
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.customOrder == targetItem.customOrder) {
callBack.upOrder()
} else {
val srcOrder = srcItem.customOrder
srcItem.customOrder = targetItem.customOrder
targetItem.customOrder = srcOrder
movedItems.add(srcItem)
movedItems.add(targetItem)
}
}
swapItem(srcPosition, targetPosition)
return true
}
private val movedItems = hashSetOf<RssSource>()
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (movedItems.isNotEmpty()) {
callBack.update(*movedItems.toTypedArray())
movedItems.clear()
}
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<RssSource>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<RssSource> {
return selected
}
override fun getItemId(position: Int): RssSource {
return getItem(position)!!
}
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
getItem(position)?.let {
if (isSelected) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(position, bundleOf(Pair("selected", null)))
callBack.upCountView()
return true
}
return false
}
}
interface CallBack {
fun del(source: RssSource)
fun edit(source: RssSource)
fun update(vararg source: RssSource)
fun toTop(source: RssSource)
fun toBottom(source: RssSource)
fun upOrder()
fun upCountView()
}
}
@@ -0,0 +1,168 @@
package io.legato.kazusa.ui.rss.source.manage
import android.app.Application
import android.text.TextUtils
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.help.DefaultData
import io.legato.kazusa.help.source.SourceHelp
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.splitNotBlank
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import java.io.File
/**
* 订阅源管理数据修改
* 修改数据要copy,直接修改会导致界面不刷新
*/
class RssSourceViewModel(application: Application) : BaseViewModel(application) {
fun topSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val minOrder = appDb.rssSourceDao.minOrder - 1
val array = Array(sources.size) {
sources[it].copy(customOrder = minOrder - it)
}
appDb.rssSourceDao.update(*array)
}
}
fun bottomSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val maxOrder = appDb.rssSourceDao.maxOrder + 1
val array = Array(sources.size) {
sources[it].copy(customOrder = maxOrder + it)
}
appDb.rssSourceDao.update(*array)
}
}
fun del(vararg rssSource: RssSource) {
execute {
SourceHelp.deleteRssSources(rssSource.toList())
}
}
fun update(vararg rssSource: RssSource) {
execute { appDb.rssSourceDao.update(*rssSource) }
}
fun upOrder() {
execute {
val sources = appDb.rssSourceDao.all
for ((index: Int, source: RssSource) in sources.withIndex()) {
source.customOrder = index + 1
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
}
fun enableSelection(sources: List<RssSource>) {
execute {
val array = Array(sources.size) {
sources[it].copy(enabled = true)
}
appDb.rssSourceDao.update(*array)
}
}
fun disableSelection(sources: List<RssSource>) {
execute {
val array = Array(sources.size) {
sources[it].copy(enabled = false)
}
appDb.rssSourceDao.update(*array)
}
}
fun saveToFile(sources: List<RssSource>, success: (file: File) -> Unit) {
execute {
val path = "${context.filesDir}/shareRssSource.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
file.writeText(GSON.toJson(sources))
file
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi(it.stackTraceStr)
}
}
fun selectionAddToGroups(sources: List<RssSource>, groups: String) {
execute {
val array = Array(sources.size) {
sources[it].copy().addGroup(groups)
}
appDb.rssSourceDao.update(*array)
}
}
fun selectionRemoveFromGroups(sources: List<RssSource>, groups: String) {
execute {
val array = Array(sources.size) {
sources[it].copy().removeGroup(groups)
}
appDb.rssSourceDao.update(*array)
}
}
fun addGroup(group: String) {
execute {
val sources = appDb.rssSourceDao.noGroup
sources.forEach { source ->
source.sourceGroup = group
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
}
fun upGroup(oldGroup: String, newGroup: String?) {
execute {
val sources = appDb.rssSourceDao.getByGroup(oldGroup)
sources.forEach { source ->
source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
it.remove(oldGroup)
if (!newGroup.isNullOrEmpty())
it.add(newGroup)
source.sourceGroup = TextUtils.join(",", it)
}
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
}
fun delGroup(group: String) {
execute {
execute {
val sources = appDb.rssSourceDao.getByGroup(group)
sources.forEach { source ->
source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
it.remove(group)
source.sourceGroup = TextUtils.join(",", it)
}
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
}
}
fun importDefault() {
execute {
DefaultData.importDefaultRssSources()
}
}
fun disable(rssSource: RssSource) {
execute {
rssSource.enabled = false
appDb.rssSourceDao.update(rssSource)
}
}
}
@@ -0,0 +1,145 @@
package io.legato.kazusa.ui.rss.subscription
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RuleSub
import io.legato.kazusa.databinding.ActivityRuleSubBinding
import io.legato.kazusa.databinding.DialogRuleSubEditBinding
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.ui.association.ImportBookSourceDialog
import io.legato.kazusa.ui.association.ImportReplaceRuleDialog
import io.legato.kazusa.ui.association.ImportRssSourceDialog
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 规则订阅界面
*/
class RuleSubActivity : BaseActivity<ActivityRuleSubBinding>(),
RuleSubAdapter.Callback {
override val binding by viewBinding(ActivityRuleSubBinding::inflate)
private val adapter by lazy { RuleSubAdapter(this, this) }
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
initData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.source_subscription, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> {
val order = appDb.ruleSubDao.maxOrder + 1
editSubscription(RuleSub(customOrder = order))
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initData() {
lifecycleScope.launch {
appDb.ruleSubDao.flowAll().catch {
AppLog.put("规则订阅界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
binding.tvEmptyMsg.isGone = it.isNotEmpty()
adapter.setItems(it)
}
}
}
override fun openSubscription(ruleSub: RuleSub) {
when (ruleSub.type) {
0 -> showDialogFragment(
ImportBookSourceDialog(ruleSub.url)
)
1 -> showDialogFragment(
ImportRssSourceDialog(ruleSub.url)
)
2 -> showDialogFragment(
ImportReplaceRuleDialog(ruleSub.url)
)
}
}
override fun editSubscription(ruleSub: RuleSub) {
alert(R.string.rule_subscription) {
val alertBinding = DialogRuleSubEditBinding.inflate(layoutInflater).apply {
spType.setSelection(ruleSub.type)
etName.setText(ruleSub.name)
etUrl.setText(ruleSub.url)
}
customView { alertBinding.root }
okButton {
lifecycleScope.launch {
ruleSub.type = alertBinding.spType.selectedItemPosition
ruleSub.name = alertBinding.etName.text?.toString() ?: ""
ruleSub.url = alertBinding.etUrl.text?.toString() ?: ""
val rs = withContext(IO) {
appDb.ruleSubDao.findByUrl(ruleSub.url)
}
if (rs != null && rs.id != ruleSub.id) {
toastOnUi("${getString(R.string.url_already)}(${rs.name})")
return@launch
}
withContext(IO) {
appDb.ruleSubDao.insert(ruleSub)
}
}
}
cancelButton()
}
}
override fun delSubscription(ruleSub: RuleSub) {
lifecycleScope.launch(IO) {
appDb.ruleSubDao.delete(ruleSub)
}
}
override fun updateSourceSub(vararg ruleSub: RuleSub) {
lifecycleScope.launch(IO) {
appDb.ruleSubDao.update(*ruleSub)
}
}
override fun upOrder() {
lifecycleScope.launch(IO) {
val sourceSubs = appDb.ruleSubDao.all
for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) {
ruleSub.customOrder = index + 1
}
appDb.ruleSubDao.update(*sourceSubs.toTypedArray())
}
}
}
@@ -0,0 +1,97 @@
package io.legato.kazusa.ui.rss.subscription
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.RuleSub
import io.legato.kazusa.databinding.ItemRuleSubBinding
import io.legato.kazusa.ui.widget.recycler.ItemTouchCallback
class RuleSubAdapter(context: Context, val callBack: Callback) :
RecyclerAdapter<RuleSub, ItemRuleSubBinding>(context),
ItemTouchCallback.Callback {
private val typeArray = context.resources.getStringArray(R.array.rule_type)
override fun convert(
holder: ItemViewHolder,
binding: ItemRuleSubBinding,
item: RuleSub,
payloads: MutableList<Any>
) {
binding.tvType.text = typeArray[item.type]
binding.tvName.text = item.name
binding.tvUrl.text = item.url
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRuleSubBinding) {
binding.root.setOnClickListener {
callBack.openSubscription(getItem(holder.layoutPosition)!!)
}
binding.ivEdit.setOnClickListener {
callBack.editSubscription(getItem(holder.layoutPosition)!!)
}
binding.ivMenuMore.setOnClickListener {
showMenu(binding.ivMenuMore, holder.layoutPosition)
}
}
private fun showMenu(view: View, position: Int) {
val source = getItem(position) ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.source_sub_item)
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menu_del -> callBack.delSubscription(source)
}
true
}
popupMenu.show()
}
override fun getViewBinding(parent: ViewGroup): ItemRuleSubBinding {
return ItemRuleSubBinding.inflate(inflater, parent, false)
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.customOrder == targetItem.customOrder) {
callBack.upOrder()
} else {
val srcOrder = srcItem.customOrder
srcItem.customOrder = targetItem.customOrder
targetItem.customOrder = srcOrder
movedItems.add(srcItem)
movedItems.add(targetItem)
}
}
swapItem(srcPosition, targetPosition)
return true
}
private val movedItems = hashSetOf<RuleSub>()
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (movedItems.isNotEmpty()) {
callBack.updateSourceSub(*movedItems.toTypedArray())
movedItems.clear()
}
}
interface Callback {
fun openSubscription(ruleSub: RuleSub)
fun editSubscription(ruleSub: RuleSub)
fun delSubscription(ruleSub: RuleSub)
fun updateSourceSub(vararg ruleSub: RuleSub)
fun upOrder()
}
}