pull/1649/head
kunfei 3 years ago
parent 439fd5b5bf
commit 7a71ffe67e
  1. 122
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt
  2. 287
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt
  3. 327
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt

@ -0,0 +1,122 @@
package io.legado.app.ui.book.changesource
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.DiffUtil
import io.legado.app.R
import io.legado.app.base.adapter.DiffRecyclerAdapter
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemChangeSourceBinding
import io.legado.app.utils.invisible
import io.legado.app.utils.visible
import splitties.views.onLongClick
class ChangeChapterSourceAdapter(
context: Context,
val viewModel: ChangeBookSourceViewModel,
val callBack: CallBack
) : DiffRecyclerAdapter<SearchBook, ItemChangeSourceBinding>(context) {
override val diffItemCallback = object : DiffUtil.ItemCallback<SearchBook>() {
override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.originName == newItem.originName
&& oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle()
}
}
override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding {
return ItemChangeSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChangeSourceBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
val bundle = payloads.getOrNull(0) as? Bundle
binding.apply {
if (bundle == null) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
if (callBack.bookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
bundle.keySet().map {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.bookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.changeTo(it)
}
}
holder.itemView.onLongClick {
showMenu(holder.itemView, getItem(holder.layoutPosition))
}
}
private fun showMenu(view: View, searchBook: SearchBook?) {
searchBook ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.change_source_item)
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_top_source -> {
callBack.topSource(searchBook)
}
R.id.menu_bottom_source -> {
callBack.bottomSource(searchBook)
}
R.id.menu_edit_source -> {
callBack.editSource(searchBook)
}
R.id.menu_disable_source -> {
callBack.disableSource(searchBook)
}
R.id.menu_delete_source -> {
callBack.deleteSource(searchBook)
updateItems(0, itemCount, listOf<Int>())
}
}
true
}
popupMenu.show()
}
interface CallBack {
val bookUrl: String?
fun changeTo(searchBook: SearchBook)
fun topSource(searchBook: SearchBook)
fun bottomSource(searchBook: SearchBook)
fun editSource(searchBook: SearchBook)
fun disableSource(searchBook: SearchBook)
fun deleteSource(searchBook: SearchBook)
}
}

@ -0,0 +1,287 @@
package io.legado.app.ui.book.changesource
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.constant.AppPattern
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.DialogChangeSourceBinding
import io.legado.app.help.AppConfig
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.*
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_change_source),
Toolbar.OnMenuItemClickListener,
ChangeBookSourceAdapter.CallBack {
constructor(name: String, author: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
}
}
private val binding by viewBinding(DialogChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeBookSourceViewModel by viewModels()
private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) }
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
viewModel.startSearch()
}
override fun onStart() {
super.onStart()
setLayout(0.9f, 0.9f)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments)
showTitle()
initMenu()
initRecyclerView()
initSearchView()
initLiveData()
}
private fun showTitle() {
binding.toolBar.title = viewModel.name
binding.toolBar.subtitle = viewModel.author
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_source)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc)
?.isChecked = AppConfig.changeSourceLoadToc
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (toPosition == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
})
}
private fun initSearchView() {
val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView
searchView.setOnCloseListener {
showTitle()
false
}
searchView.setOnSearchClickListener {
binding.toolBar.title = ""
binding.toolBar.subtitle = ""
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.screen(newText)
return false
}
})
}
private fun initLiveData() {
viewModel.searchStateData.observe(viewLifecycleOwner) {
binding.refreshProgressBar.isAutoLoading = it
if (it) {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
} else {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_refresh_black_24dp)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
launch {
viewModel.searchDataFlow
.collect {
adapter.setItems(it)
delay(1000)
}
}
launch {
appDb.bookSourceDao.flowGroupEnabled().collect {
groups.clear()
it.map { group ->
groups.addAll(group.splitNotBlank(AppPattern.splitGroupRegex))
}
upGroupMenu()
}
}
}
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_toc -> {
putPrefBoolean(PreferKey.changeSourceLoadToc, !item.isChecked)
item.isChecked = !item.isChecked
}
R.id.menu_load_info -> {
putPrefBoolean(PreferKey.changeSourceLoadInfo, !item.isChecked)
item.isChecked = !item.isChecked
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
else -> if (item?.groupId == R.id.source_group) {
if (!item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
putPrefString("searchGroup", "")
} else {
putPrefString("searchGroup", item.title.toString())
}
viewModel.startOrStopSearch()
viewModel.refresh()
}
}
}
return false
}
override fun changeTo(searchBook: SearchBook) {
changeSource(searchBook)
dismissAllowingStateLoss()
}
override val bookUrl: String?
get() = callBack?.oldBook?.bookUrl
override fun topSource(searchBook: SearchBook) {
viewModel.topSource(searchBook)
}
override fun bottomSource(searchBook: SearchBook) {
viewModel.bottomSource(searchBook)
}
override fun editSource(searchBook: SearchBook) {
editSourceResult.launch {
putExtra("sourceUrl", searchBook.origin)
}
}
override fun disableSource(searchBook: SearchBook) {
viewModel.disableSource(searchBook)
}
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (bookUrl == searchBook.bookUrl) {
viewModel.firstSourceOrNull(searchBook)?.let {
changeSource(it)
}
}
}
private fun changeSource(searchBook: SearchBook) {
try {
val book = searchBook.toBook()
book.upInfoFromOld(callBack?.oldBook)
val source = appDb.bookSourceDao.getBookSource(book.origin)
callBack?.changeTo(source!!, book)
searchBook.time = System.currentTimeMillis()
viewModel.updateSource(searchBook)
} catch (e: Exception) {
toastOnUi("换源失败\n${e.localizedMessage}")
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
val menu: Menu = binding.toolBar.menu
val selectedGroup = getPrefString("searchGroup")
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
groups.sortedWith { o1, o2 ->
o1.cnCompare(o2)
}.forEach { group ->
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let {
if (group == selectedGroup) {
it.isChecked = true
hasSelectedGroup = true
}
}
}
menu.setGroupCheckable(R.id.source_group, true, true)
if (!hasSelectedGroup) {
allItem.isChecked = true
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.SOURCE_CHANGED) {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
bundleOf(Pair("upCurSource", bookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book)
}
}

@ -0,0 +1,327 @@
package io.legado.app.ui.book.changesource
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import cn.hutool.core.collection.ConcurrentHashSet
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppPattern
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import splitties.init.appCtx
import timber.log.Timber
import java.util.concurrent.Executors
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
class ChangeChapterSourceViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData<Boolean>()
var name: String = ""
var author: String = ""
private var tasks = CompositeCoroutine()
private var screenKey: String = ""
private var bookSourceList = arrayListOf<BookSource>()
private val searchBooks = ConcurrentHashSet<SearchBook>()
private val searchGroup get() = appCtx.getPrefString("searchGroup") ?: ""
private var searchCallback: SourceCallback? = null
val searchDataFlow = callbackFlow {
searchCallback = object : SourceCallback {
override fun searchSuccess(searchBook: SearchBook) {
appDb.searchBookDao.insert(searchBook)
when {
screenKey.isEmpty() -> searchBooks.add(searchBook)
searchBook.name.contains(screenKey) -> searchBooks.add(searchBook)
else -> return
}
trySend(searchBooks)
}
override fun upAdapter() {
trySend(searchBooks)
}
}
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
trySend(searchBooks)
}
if (searchBooks.size <= 1) {
startSearch()
}
awaitClose {
searchCallback = null
}
}.conflate()
.map {
searchBooks.sortedBy { it.originOrder }
}.flowOn(IO)
@Volatile
private var searchIndex = -1
fun initData(arguments: Bundle?) {
arguments?.let { bundle ->
bundle.getString("name")?.let {
name = it
}
bundle.getString("author")?.let {
author = it.replace(AppPattern.authorRegex, "")
}
}
}
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
searchIndex = -1
}
fun refresh() {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
}
fun startSearch() {
execute {
stopSearch()
appDb.searchBookDao.clear(name, author)
searchBooks.clear()
bookSourceList.clear()
if (searchGroup.isBlank()) {
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
val sources = appDb.bookSourceDao.getEnabledByGroup(searchGroup)
if (sources.isEmpty()) {
bookSourceList.addAll(appDb.bookSourceDao.allEnabled)
} else {
bookSourceList.addAll(sources)
}
}
searchStateData.postValue(true)
initSearchPool()
for (i in 0 until threadCount) {
search()
}
}
}
private fun search() {
synchronized(this) {
if (searchIndex >= bookSourceList.lastIndex) {
return
}
searchIndex++
}
val source = bookSourceList[searchIndex]
val task = WebBook
.searchBook(viewModelScope, source, name, context = searchPool!!)
.timeout(60000L)
.onSuccess(IO) {
it.forEach { searchBook ->
if (searchBook.name == name) {
if ((AppConfig.changeSourceCheckAuthor && searchBook.author.contains(author))
|| !AppConfig.changeSourceCheckAuthor
) {
if (searchBook.latestChapterTitle.isNullOrEmpty()) {
if (AppConfig.changeSourceLoadInfo || AppConfig.changeSourceLoadToc) {
loadBookInfo(source, searchBook.toBook())
} else {
searchCallback?.searchSuccess(searchBook)
}
} else {
searchCallback?.searchSuccess(searchBook)
}
}
}
}
}
.onFinally(searchPool) {
synchronized(this) {
if (searchIndex < bookSourceList.lastIndex) {
search()
} else {
searchIndex++
}
if (searchIndex >= bookSourceList.lastIndex + bookSourceList.size
|| searchIndex >= bookSourceList.lastIndex + threadCount
) {
searchStateData.postValue(false)
tasks.clear()
}
}
}
tasks.add(task)
}
private fun loadBookInfo(source: BookSource, book: Book) {
WebBook.getBookInfo(viewModelScope, source, book, context = searchPool!!)
.onSuccess(IO) {
if (context.getPrefBoolean(PreferKey.changeSourceLoadToc)) {
loadBookToc(source, book)
} else {
//从详情页里获取最新章节
book.latestChapterTitle = it.latestChapterTitle
val searchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}
}.onError(IO) {
Timber.e(it)
}
}
private fun loadBookToc(source: BookSource, book: Book) {
WebBook.getChapterList(viewModelScope, source, book, context = searchPool!!)
.onSuccess(IO) { chapters ->
book.latestChapterTitle = chapters.last().title
val searchBook: SearchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}.onError(IO) {
Timber.e(it)
}
}
private fun getDbSearchBooks(): List<SearchBook> {
return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.getChangeSourceSearch(name, author, searchGroup)
} else {
appDb.searchBookDao.getChangeSourceSearch(name, "", searchGroup)
}
} else {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.getChangeSourceSearch(name, author, screenKey, searchGroup)
} else {
appDb.searchBookDao.getChangeSourceSearch(name, "", screenKey, searchGroup)
}
}
}
/**
* 筛选
*/
fun screen(key: String?) {
screenKey = key?.trim() ?: ""
execute {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
}
}
fun startOrStopSearch() {
if (tasks.isEmpty) {
startSearch()
} else {
stopSearch()
}
}
fun stopSearch() {
tasks.clear()
searchPool?.close()
searchStateData.postValue(false)
}
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
fun disableSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
source.enabled = false
appDb.bookSourceDao.update(source)
}
searchBooks.remove(searchBook)
searchCallback?.upAdapter()
}
}
fun topSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
val minOrder = appDb.bookSourceDao.minOrder - 1
source.customOrder = minOrder
searchBook.originOrder = source.customOrder
appDb.bookSourceDao.update(source)
updateSource(searchBook)
}
searchCallback?.upAdapter()
}
}
fun bottomSource(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
val maxOrder = appDb.bookSourceDao.maxOrder + 1
source.customOrder = maxOrder
searchBook.originOrder = source.customOrder
appDb.bookSourceDao.update(source)
updateSource(searchBook)
}
searchCallback?.upAdapter()
}
}
fun updateSource(searchBook: SearchBook) {
appDb.searchBookDao.update(searchBook)
}
fun del(searchBook: SearchBook) {
execute {
appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
appDb.bookSourceDao.delete(source)
appDb.searchBookDao.delete(searchBook)
}
}
searchBooks.remove(searchBook)
searchCallback?.upAdapter()
}
fun firstSourceOrNull(searchBook: SearchBook): SearchBook? {
return searchBooks.firstOrNull { it.bookUrl != searchBook.bookUrl }
}
interface SourceCallback {
fun searchSuccess(searchBook: SearchBook)
fun upAdapter()
}
}
Loading…
Cancel
Save