diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt new file mode 100644 index 000000000..e74fc431a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.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(context) { + + override val diffItemCallback = object : DiffUtil.ItemCallback() { + 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 + ) { + 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()) + } + } + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt new file mode 100644 index 000000000..2f12049ef --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt @@ -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() + 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() + 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(EventBus.SOURCE_CHANGED) { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + bundleOf(Pair("upCurSource", bookUrl)) + ) + } + } + + interface CallBack { + val oldBook: Book? + fun changeTo(source: BookSource, book: Book) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt new file mode 100644 index 000000000..a78743614 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt @@ -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() + var name: String = "" + var author: String = "" + private var tasks = CompositeCoroutine() + private var screenKey: String = "" + private var bookSourceList = arrayListOf() + private val searchBooks = ConcurrentHashSet() + 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 { + 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() + + } + +} \ No newline at end of file