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,26 @@
# 放置与界面有关的类
* about 关于界面
* association 导入书源界面
* book\audio 音频播放界面
* book\arrange 书架整理界面
* book\info 书籍信息查看
* book\read 书籍阅读界面
* book\search 搜索书籍界面
* book\source 书源界面
* book\changeCover 封面换源界面
* book\changeSource 换源界面
* book\toc 目录界面
* book\download 下载界面
* book\explore 发现界面
* book\local 书籍导入界面
* document 文件选择界面
* config 配置界面
* main 主界面
* qrCode 二维码扫描界面
* replaceRule 替换净化界面
* rss\article 订阅条目界面
* rss\read 订阅阅读界面
* rss\source 订阅源界面
* welcome 欢迎界面
* widget 自定义插件
@@ -0,0 +1,69 @@
package io.legato.kazusa.ui.about
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.util.TypedValue
import android.view.Menu
import android.view.MenuItem
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.databinding.ActivityAboutBinding
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.filletBackground
import io.legato.kazusa.utils.openUrl
import io.legato.kazusa.utils.share
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class AboutActivity : BaseActivity<ActivityAboutBinding>() {
override val binding by viewBinding(ActivityAboutBinding::inflate)
override fun onActivityCreated(savedInstanceState: Bundle?) {
//binding.llAbout.background = filletBackground
val fTag = "aboutFragment"
var aboutFragment = supportFragmentManager.findFragmentByTag(fTag)
if (aboutFragment == null) aboutFragment = AboutFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.fl_fragment, aboutFragment, fTag)
.commit()
binding.tvAppSummary.post {
kotlin.runCatching {
val typedValue = TypedValue()
val context = binding.root.context
context.theme.resolveAttribute(
com.google.android.material.R.attr.colorPrimary,
typedValue,
true
)
val accentColor = typedValue.data
val spannableString = SpannableString(binding.tvAppSummary.text)
val gzh = getString(R.string.legado_gzh)
val start = spannableString.indexOf(gzh)
spannableString.setSpan(
accentColor, start, start + gzh.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
binding.tvAppSummary.text = spannableString
}
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.about, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_scoring -> openUrl("market://details?id=$packageName")
R.id.menu_share_it -> share(
getString(R.string.app_share_description),
getString(R.string.app_name)
)
}
return super.onCompatOptionsItemSelected(item)
}
}
@@ -0,0 +1,214 @@
package io.legato.kazusa.ui.about
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppConst.appInfo
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.help.CrashHandler
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.update.AppUpdate
import io.legato.kazusa.ui.widget.dialog.TextDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.compress.ZipUtils
import io.legato.kazusa.utils.createFileIfNotExist
import io.legato.kazusa.utils.createFolderIfNotExist
import io.legato.kazusa.utils.delete
import io.legato.kazusa.utils.externalCache
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.list
import io.legato.kazusa.utils.openInputStream
import io.legato.kazusa.utils.openOutputStream
import io.legato.kazusa.utils.openUrl
import io.legato.kazusa.utils.sendMail
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.delay
import splitties.init.appCtx
import java.io.File
class AboutFragment : PreferenceFragmentCompat() {
private val waitDialog by lazy {
WaitDialog(requireContext())
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.about)
findPreference<Preference>("update_log")?.summary =
"${getString(R.string.version)} ${appInfo.versionName}"
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listView.overScrollMode = View.OVER_SCROLL_NEVER
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
"contributors" -> openUrl(R.string.contributors_url)
"update_log" -> showMdFile(getString(R.string.update_log), "updateLog.md")
"check_update" -> checkUpdate()
"mail" -> requireContext().sendMail(getString(R.string.email))
"license" -> showMdFile(getString(R.string.license), "LICENSE.md")
"disclaimer" -> showMdFile(getString(R.string.disclaimer), "disclaimer.md")
"privacyPolicy" -> showMdFile(getString(R.string.privacy_policy), "privacyPolicy.md")
"gzGzh" -> requireContext().sendToClip(getString(R.string.legado_gzh))
"crashLog" -> showDialogFragment<CrashLogsDialog>()
"saveLog" -> saveLog()
"createHeapDump" -> createHeapDump()
}
return super.onPreferenceTreeClick(preference)
}
@Suppress("SameParameterValue")
private fun openUrl(@StringRes addressID: Int) {
requireContext().openUrl(getString(addressID))
}
/**
* 显示md文件
*/
private fun showMdFile(title: String, fileName: String) {
val mdText = String(requireContext().assets.open(fileName).readBytes())
showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
}
/**
* 检测更新
*/
private fun checkUpdate() {
waitDialog.show()
AppUpdate.gitHubUpdate?.run {
check(lifecycleScope)
.onSuccess {
showDialogFragment(
UpdateDialog(it)
)
}.onError {
appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}")
}.onFinally {
waitDialog.dismiss()
}
}
}
/**
* 加入qq群
*/
private fun joinQQGroup(key: String): Boolean {
val intent = Intent()
intent.data =
Uri.parse("mqqopensdkapi://bizAgent/qm/qr?url=http%3A%2F%2Fqm.qq.com%2Fcgi-bin%2Fqm%2Fqr%3Ffrom%3Dapp%26p%3Dandroid%26k%3D$key")
// 此Flag可根据具体产品需要自定义,如设置,则在加群界面按返回,返回手Q主界面,不设置,按返回会返回到呼起产品界面
// intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
kotlin.runCatching {
startActivity(intent)
return true
}.onFailure {
toastOnUi("添加失败,请手动添加")
}
return false
}
private fun saveLog() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
appCtx.toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordLog) {
appCtx.toastOnUi("未开启日志记录,请去其他设置里打开记录日志")
delay(3000)
}
val doc = FileDoc.fromUri(Uri.parse(backupPath), true)
copyLogs(doc)
copyHeapDump(doc)
appCtx.toastOnUi("已保存至备份目录")
}.onError {
AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
}
}
private fun createHeapDump() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
appCtx.toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordHeapDump) {
appCtx.toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储")
delay(3000)
}
appCtx.toastOnUi("开始创建堆转储")
System.gc()
CrashHandler.doHeapDump(true)
val doc = FileDoc.fromUri(Uri.parse(backupPath), true)
if (!copyHeapDump(doc)) {
appCtx.toastOnUi("未找到堆转储文件")
} else {
appCtx.toastOnUi("已保存至备份目录")
}
}.onError {
AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
}
}
private fun copyLogs(doc: FileDoc) {
val cacheDir = appCtx.externalCache
val logFiles = File(cacheDir, "logs")
val crashFiles = File(cacheDir, "crash")
val logcatFile = File(cacheDir, "logcat.txt")
dumpLogcat(logcatFile)
val zipFile = File(cacheDir, "logs.zip")
ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile)
doc.find("logs.zip")?.delete()
zipFile.inputStream().use { input ->
doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
zipFile.delete()
}
private fun copyHeapDump(doc: FileDoc): Boolean {
val heapFile = FileDoc.fromFile(File(appCtx.externalCache, "heapDump")).list()
?.firstOrNull() ?: return false
doc.find("heapDump")?.delete()
val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
heapFile.openInputStream().getOrNull()?.use { input ->
heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
return true
}
private fun dumpLogcat(file: File) {
try {
val process = Runtime.getRuntime().exec("logcat -d")
file.outputStream().use {
process.inputStream.copyTo(it)
}
} catch (e: Exception) {
AppLog.put("保存Logcat失败\n$e", e)
}
}
}
@@ -0,0 +1,90 @@
package io.legato.kazusa.ui.about
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.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.constant.AppLog
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemAppLogBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.TextDialog
import io.legato.kazusa.utils.LogUtils
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onClick
import java.util.*
class AppLogDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy {
LogAdapter(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.log)
toolBar.inflateMenu(R.menu.app_log)
toolBar.setOnMenuItemClickListener(this@AppLogDialog)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
}
adapter.setItems(AppLog.logs)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_clear -> {
AppLog.clear()
adapter.clearItems()
}
}
return true
}
inner class LogAdapter(context: Context) :
RecyclerAdapter<Triple<Long, String, Throwable?>, ItemAppLogBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemAppLogBinding {
return ItemAppLogBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemAppLogBinding,
item: Triple<Long, String, Throwable?>,
payloads: MutableList<Any>
) {
binding.textTime.text = LogUtils.logTimeFormat.format(Date(item.first))
binding.textMessage.text = item.second
}
override fun registerListener(holder: ItemViewHolder, binding: ItemAppLogBinding) {
binding.root.onClick {
getItem(holder.layoutPosition)?.let { item ->
item.third?.let {
showDialogFragment(TextDialog("Log", it.stackTraceToString()))
}
}
}
}
}
}
@@ -0,0 +1,166 @@
package io.legato.kazusa.ui.about
import android.app.Application
import android.net.Uri
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.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.Item1lineTextBinding
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.TextDialog
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.delete
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.getFile
import io.legato.kazusa.utils.list
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.isActive
import java.io.FileFilter
class CrashLogsDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<CrashViewModel>()
private val adapter by lazy { LogAdapter() }
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.crash_log)
binding.toolBar.inflateMenu(R.menu.crash_log)
binding.toolBar.setOnMenuItemClickListener(this)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
viewModel.logLiveData.observe(viewLifecycleOwner) {
adapter.setItems(it)
}
viewModel.initData()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_clear -> viewModel.clearCrashLog()
}
return true
}
private fun showLogFile(fileDoc: FileDoc) {
viewModel.readFile(fileDoc) {
if (lifecycleScope.isActive) {
showDialogFragment(TextDialog(fileDoc.name, it))
}
}
}
inner class LogAdapter : RecyclerAdapter<FileDoc, Item1lineTextBinding>(requireContext()) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false)
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { item ->
showLogFile(item)
}
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: FileDoc,
payloads: MutableList<Any>
) {
binding.textView.text = item.name
}
}
class CrashViewModel(application: Application) : BaseViewModel(application) {
val logLiveData = MutableLiveData<List<FileDoc>>()
fun initData() {
execute {
val list = arrayListOf<FileDoc>()
context.externalCacheDir
?.getFile("crash")
?.listFiles(FileFilter { it.isFile })
?.forEach {
list.add(FileDoc.fromFile(it))
}
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.list {
!it.isDir
}?.let {
list.addAll(it)
}
}
return@execute list.sortedByDescending { it.name }.distinctBy { it.name }
}.onSuccess {
logLiveData.postValue(it)
}
}
fun readFile(fileDoc: FileDoc, success: (String) -> Unit) {
execute {
String(fileDoc.readBytes())
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi(it.localizedMessage)
}
}
fun clearCrashLog() {
execute {
context.externalCacheDir
?.getFile("crash")
?.let {
FileUtils.delete(it, false)
}
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.delete()
}
}.onError {
context.toastOnUi(it.localizedMessage)
}.onFinally {
initData()
}
}
}
}
@@ -0,0 +1,234 @@
package io.legato.kazusa.ui.about
import android.content.Context
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.ReadRecordShow
import io.legato.kazusa.databinding.ActivityReadRecordBinding
import io.legato.kazusa.databinding.ItemReadRecordBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.ui.book.search.SearchActivity
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.cnCompare
import io.legato.kazusa.utils.getInt
import io.legato.kazusa.utils.putInt
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Locale
class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
private val adapter by lazy { RecordAdapter(this) }
private var sortMode
get() = LocalConfig.getInt("readRecordSort")
set(value) {
LocalConfig.putInt("readRecordSort", value)
}
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
override val binding by viewBinding(ActivityReadRecordBinding::inflate)
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
initAllTime()
initData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_read_record, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_record)?.isChecked = AppConfig.enableReadRecord
when (sortMode) {
1 -> menu.findItem(R.id.menu_sort_read_long)?.isChecked = true
2 -> menu.findItem(R.id.menu_sort_read_time)?.isChecked = true
else -> menu.findItem(R.id.menu_sort_name)?.isChecked = true
}
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_sort_name -> {
sortMode = 0
item.isChecked = true
initData()
}
R.id.menu_sort_read_long -> {
sortMode = 1
item.isChecked = true
initData()
}
R.id.menu_sort_read_time -> {
sortMode = 2
item.isChecked = true
initData()
}
R.id.menu_enable_record -> {
AppConfig.enableReadRecord = !item.isChecked
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
initSearchView()
binding.tvBookName.setText(R.string.all_read_time)
binding.tvRemove.setOnClickListener {
alert(R.string.delete, R.string.sure_del) {
yesButton {
appDb.readRecordDao.clear()
initData()
}
noButton()
}
}
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
searchView.clearFocus()
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initAllTime() {
lifecycleScope.launch {
val allTime = withContext(IO) {
appDb.readRecordDao.allTime
}
binding.tvReadingTime.text = formatDuring(allTime)
}
}
private fun initData(searchKey: String? = null) {
lifecycleScope.launch {
val readRecords = withContext(IO) {
appDb.readRecordDao.search(searchKey ?: "").let { records ->
when (sortMode) {
1 -> records.sortedByDescending { it.readTime }
2 -> records.sortedByDescending { it.lastRead }
else -> records.sortedWith { o1, o2 ->
o1.bookName.cnCompare(o2.bookName)
}
}
}
}
adapter.setItems(readRecords)
}
}
inner class RecordAdapter(context: Context) :
RecyclerAdapter<ReadRecordShow, ItemReadRecordBinding>(context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding {
return ItemReadRecordBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemReadRecordBinding,
item: ReadRecordShow,
payloads: MutableList<Any>,
) {
binding.apply {
tvBookName.text = item.bookName
tvReadingTime.text = formatDuring(item.readTime)
if (item.lastRead > 0) {
tvLastReadTime.text = dateFormat.format(item.lastRead)
} else {
tvLastReadTime.text = ""
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) {
binding.apply {
root.setOnClickListener {
val item = getItem(holder.layoutPosition) ?: return@setOnClickListener
lifecycleScope.launch {
val book = withContext(IO) {
appDb.bookDao.findByName(item.bookName).firstOrNull()
}
if (book == null) {
SearchActivity.start(this@ReadRecordActivity, item.bookName)
} else {
startActivityForBook(book)
}
}
}
tvRemove.setOnClickListener {
getItem(holder.layoutPosition)?.let { item ->
sureDelAlert(item)
}
}
}
}
private fun sureDelAlert(item: ReadRecordShow) {
alert(R.string.delete) {
setMessage(getString(R.string.sure_del_any, item.bookName))
yesButton {
appDb.readRecordDao.deleteByName(item.bookName)
initData()
}
noButton()
}
}
}
fun formatDuring(mss: Long): String {
val days = mss / (1000 * 60 * 60 * 24)
val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
val seconds = mss % (1000 * 60) / 1000
val d = if (days > 0) "${days}天" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}秒" else ""
var time = "$d$h$m$s"
if (time.isBlank()) {
time = "0秒"
}
return time
}
}
@@ -0,0 +1,71 @@
package io.legato.kazusa.ui.about
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.databinding.DialogUpdateBinding
import io.legato.kazusa.help.update.AppUpdate
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.Download
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.noties.markwon.Markwon
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.glide.GlideImagesPlugin
class UpdateDialog() : BaseDialogFragment(R.layout.dialog_update) {
constructor(updateInfo: AppUpdate.UpdateInfo) : this() {
arguments = Bundle().apply {
putString("newVersion", updateInfo.tagName)
putString("updateBody", updateInfo.updateLog)
putString("url", updateInfo.downloadUrl)
putString("name", updateInfo.fileName)
}
}
val binding by viewBinding(DialogUpdateBinding::bind)
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = arguments?.getString("newVersion")
val updateBody = arguments?.getString("updateBody")
if (updateBody == null) {
toastOnUi("没有数据")
dismiss()
return
}
binding.textView.post {
Markwon.builder(requireContext())
.usePlugin(GlideImagesPlugin.create(requireContext()))
.usePlugin(HtmlPlugin.create())
.usePlugin(TablePlugin.create(requireContext()))
.build()
.setMarkdown(binding.textView, updateBody)
}
binding.toolBar.inflateMenu(R.menu.app_update)
binding.toolBar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_download -> {
val url = arguments?.getString("url")
val name = arguments?.getString("name")
if (url != null && name != null) {
Download.start(requireContext(), url, name)
toastOnUi(R.string.download_start)
}
}
}
return@setOnMenuItemClickListener true
}
}
}
@@ -0,0 +1,177 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.app.Application
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.DialogAddToBookshelfBinding
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 添加书籍链接到书架,需要对应网站书源
* ${origin}/${path}, {origin: bookSourceUrl}
* 按以下顺序尝试匹配书源并添加网址
* - UrlOption中的指定的书源网址bookSourceUrl
* - 在所有启用的书源中匹配orgin
* - 在所有启用的书源中使用详情页正则匹配${origin}/${path}, {origin: bookSourceUrl}
*/
class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshelf) {
constructor(bookUrl: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("bookUrl", bookUrl)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
val binding by viewBinding(DialogAddToBookshelfBinding::bind)
val viewModel by viewModels<ViewModel>()
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("SetTextI18n")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val bookUrl = arguments?.getString("bookUrl")
if (bookUrl.isNullOrBlank()) {
toastOnUi("url不能为空")
dismiss()
return
}
viewModel.loadStateLiveData.observe(this) {
if (it) {
binding.rotateLoading.visible()
} else {
binding.rotateLoading.gone()
}
}
viewModel.loadErrorLiveData.observe(this) {
toastOnUi(it)
dismiss()
}
viewModel.load(bookUrl) {
viewModel.saveSearchBook(it) {
startActivity<BookInfoActivity> {
putExtra("name", it.name)
putExtra("author", it.author)
putExtra("bookUrl", it.bookUrl)
}
dismiss()
}
}
binding.tvCancel.setOnClickListener {
dismiss()
}
}
class ViewModel(application: Application) : BaseViewModel(application) {
val loadStateLiveData = MutableLiveData<Boolean>()
val loadErrorLiveData = MutableLiveData<String>()
var book: Book? = null
fun load(bookUrl: String, success: (book: Book) -> Unit) {
execute {
appDb.bookDao.getBook(bookUrl)?.let {
throw NoStackTraceException("${it.name} 已在书架")
}
val baseUrl = NetworkUtils.getBaseUrl(bookUrl)
?: throw NoStackTraceException("书籍地址格式不对")
val urlMatcher = AnalyzeUrl.paramPattern.matcher(bookUrl)
if (urlMatcher.find()) {
val origin = GSON.fromJsonObject<AnalyzeUrl.UrlOption>(
bookUrl.substring(urlMatcher.end())
).getOrNull()?.getOrigin()
origin?.let {
val source = appDb.bookSourceDao.getBookSource(it)
source?.let {
getBookInfo(bookUrl, source)?.let { book ->
return@execute book
}
}
}
}
appDb.bookSourceDao.getBookSourceAddBook(baseUrl)?.let { source ->
getBookInfo(bookUrl, source)?.let { book ->
return@execute book
}
}
appDb.bookSourceDao.hasBookUrlPattern.forEach { source ->
try {
val bs = source.getBookSource()!!
if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) {
getBookInfo(bookUrl, bs)?.let { book ->
return@execute book
}
}
} catch (_: Exception) {
}
}
throw NoStackTraceException("未找到匹配书源")
}.onError {
AppLog.put("添加书籍 $bookUrl 出错", it)
loadErrorLiveData.postValue(it.localizedMessage)
}.onSuccess {
book = it
success.invoke(it)
}.onStart {
loadStateLiveData.postValue(true)
}.onFinally {
loadStateLiveData.postValue(false)
}
}
private suspend fun getBookInfo(bookUrl: String, source: BookSource): Book? {
return kotlin.runCatching {
val book = Book(
bookUrl = bookUrl,
origin = source.bookSourceUrl,
originName = source.bookSourceName
)
WebBook.getBookInfoAwait(source, book)
}.getOrNull()
}
fun saveSearchBook(book: Book, success: () -> Unit) {
execute {
val searchBook = book.toSearchBook()
appDb.searchBookDao.insert(searchBook)
searchBook
}.onSuccess {
success.invoke()
}
}
}
}
@@ -0,0 +1,43 @@
package io.legato.kazusa.ui.association
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.utils.contains
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.readText
abstract class BaseAssociationViewModel(application: Application) : BaseViewModel(application) {
val successLive = MutableLiveData<Pair<String, String>>()
val errorLive = MutableLiveData<String>()
fun importJson(uri: Uri) {
when {
uri.inputStream(context).getOrNull().contains("bookSourceUrl") ->
successLive.postValue(Pair("bookSource", uri.toString()))
else -> importJson(uri.readText(context))
}
}
private fun importJson(json: String) {
//暂时根据文件内容判断属于什么
when {
json.contains("sourceUrl") ->
successLive.postValue(Pair("rssSource", json))
json.contains("pattern") ->
successLive.postValue(Pair("replaceRule", json))
json.contains("themeName") ->
successLive.postValue(Pair("theme", json))
json.contains("urlRule") && json.contains("showRule") ->
successLive.postValue(Pair("dictRule", json))
json.contains("name") && json.contains("rule") ->
successLive.postValue(Pair("txtRule", json))
json.contains("name") && json.contains("url") ->
successLive.postValue(Pair("httpTts", json))
else -> errorLive.postValue("格式不对")
}
}
}
@@ -0,0 +1,219 @@
package io.legato.kazusa.ui.association
import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.os.postDelayed
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.databinding.ActivityTranslucenceBinding
import io.legato.kazusa.exception.InvalidBooksDirException
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.permission.Permissions
import io.legato.kazusa.lib.permission.PermissionsCompat
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.buildMainHandler
import io.legato.kazusa.utils.canRead
import io.legato.kazusa.utils.checkWrite
import io.legato.kazusa.utils.getFile
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import java.io.File
import java.io.FileOutputStream
class FileAssociationActivity :
VMBaseActivity<ActivityTranslucenceBinding, FileAssociationViewModel>() {
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
intent.data?.let { uri ->
it.uri?.let { treeUri ->
AppConfig.defaultBookTreeUri = treeUri.toString()
importBook(treeUri, uri)
} ?: let {
val storageHelp = String(assets.open("storageHelp.md").readBytes())
toastOnUi(storageHelp)
importBook(null, uri)
}
}
}
override val binding by viewBinding(ActivityTranslucenceBinding::inflate)
override val viewModel by viewModels<FileAssociationViewModel>()
private val handler by lazy {
buildMainHandler()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.rotateLoading.visible()
viewModel.importBookLiveData.observe(this) { uri ->
importBook(uri)
}
viewModel.onLineImportLive.observe(this) {
startActivity<OnLineImportActivity> {
data = it
}
finish()
}
viewModel.successLive.observe(this) {
when (it.first) {
"bookSource" -> showDialogFragment(ImportBookSourceDialog(it.second, true))
"rssSource" -> showDialogFragment(ImportRssSourceDialog(it.second, true))
"replaceRule" -> showDialogFragment(ImportReplaceRuleDialog(it.second, true))
"httpTts" -> showDialogFragment(ImportHttpTtsDialog(it.second, true))
"theme" -> showDialogFragment(ImportThemeDialog(it.second, true))
"txtRule" -> showDialogFragment(ImportTxtTocRuleDialog(it.second, true))
"dictRule" -> showDialogFragment(ImportDictRuleDialog(it.second, true))
}
}
viewModel.errorLive.observe(this) {
binding.rotateLoading.gone()
toastOnUi(it)
handler.postDelayed(2000) {
finish()
}
}
viewModel.openBookLiveData.observe(this) {
binding.rotateLoading.gone()
startActivityForBook(it)
finish()
}
viewModel.notSupportedLiveData.observe(this) { data ->
binding.rotateLoading.gone()
alert(
title = appCtx.getString(R.string.draw),
message = appCtx.getString(R.string.file_not_supported, data.second)
) {
yesButton {
importBook(data.first)
}
noButton {
finish()
}
onCancelled {
finish()
}
}
}
intent.data?.let { data ->
if (data.isContentScheme() && data.canRead()) {
viewModel.dispatchIntent(data)
} else {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
viewModel.dispatchIntent(data)
}.onDenied {
toastOnUi("请求存储权限失败。")
handler.postDelayed(2000) {
finish()
}
}.request()
}
} ?: finish()
}
private fun importBook(uri: Uri) {
if (uri.isContentScheme()) {
val treeUriStr = AppConfig.defaultBookTreeUri
if (treeUriStr.isNullOrEmpty()) {
localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
mode = HandleFileContract.DIR_SYS
}
} else {
importBook(Uri.parse(treeUriStr), uri)
}
} else {
importBook(null, uri)
}
}
private fun importBook(treeUri: Uri?, uri: Uri) {
lifecycleScope.launch {
runCatching {
withContext(IO) {
if (treeUri == null) {
viewModel.importBook(uri)
} else if (treeUri.isContentScheme()) {
val treeDoc =
DocumentFile.fromTreeUri(this@FileAssociationActivity, treeUri)
if (!treeDoc!!.checkWrite()) {
throw InvalidBooksDirException(
"请重新设置书籍保存位置\nPermission Denial"
)
}
readUri(uri) { fileDoc, inputStream ->
val name = fileDoc.name
var doc = treeDoc.findFile(name)
if (doc == null || fileDoc.lastModified > doc.lastModified()) {
if (doc == null) {
doc = treeDoc.createFile(FileUtils.getMimeType(name), name)
?: throw InvalidBooksDirException(
"请重新设置书籍保存位置\nPermission Denial"
)
}
contentResolver.openOutputStream(doc.uri)!!.use { oStream ->
inputStream.copyTo(oStream)
oStream.flush()
}
}
viewModel.importBook(doc.uri)
}
} else {
val treeFile = File(treeUri.path ?: treeUri.toString())
if (!treeFile.checkWrite()) {
throw InvalidBooksDirException(
"请重新设置书籍保存位置\nPermission Denial"
)
}
readUri(uri) { fileDoc, inputStream ->
val name = fileDoc.name
val file = treeFile.getFile(name)
if (!file.exists() || fileDoc.lastModified > file.lastModified()) {
FileOutputStream(file).use { oStream ->
inputStream.copyTo(oStream)
oStream.flush()
}
}
viewModel.importBook(Uri.fromFile(file))
}
}
}
}.onFailure {
when (it) {
is InvalidBooksDirException -> localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
mode = HandleFileContract.DIR_SYS
}
else -> {
val msg = "导入书籍失败\n${it.localizedMessage}"
AppLog.put(msg, it)
toastOnUi(msg)
handler.postDelayed(2000) {
finish()
}
}
}
}
}
}
}
@@ -0,0 +1,66 @@
package io.legato.kazusa.ui.association
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.constant.AppPattern.bookFileRegex
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.*
class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) {
val importBookLiveData = MutableLiveData<Uri>()
val onLineImportLive = MutableLiveData<Uri>()
val openBookLiveData = MutableLiveData<Book>()
val notSupportedLiveData = MutableLiveData<Pair<Uri, String>>()
fun dispatchIntent(uri: Uri) {
execute {
//如果是普通的url,需要根据返回的内容判断是什么
if (uri.isContentScheme() || uri.isFileScheme()) {
val fileDoc = FileDoc.fromUri(uri, false)
val fileName = fileDoc.name
if (fileName.matches(AppPattern.archiveFileRegex)) {
ArchiveUtils.deCompress(fileDoc, ArchiveUtils.TEMP_PATH) {
it.matches(bookFileRegex)
}.forEach {
dispatch(FileDoc.fromFile(it))
}
} else {
dispatch(fileDoc)
}
} else {
onLineImportLive.postValue(uri)
}
}.onError {
it.printOnDebug()
val msg = "无法打开文件\n${it.localizedMessage}"
errorLive.postValue(msg)
AppLog.put(msg, it)
}
}
private fun dispatch(fileDoc: FileDoc) {
kotlin.runCatching {
if (fileDoc.openInputStream().getOrNull().isJson()) {
importJson(fileDoc.uri)
return
}
}.onFailure {
it.printOnDebug()
AppLog.put("尝试导入为JSON文件失败\n${it.localizedMessage}", it)
}
if (fileDoc.name.matches(bookFileRegex)) {
importBookLiveData.postValue(fileDoc.uri)
return
}
notSupportedLiveData.postValue(Pair(fileDoc.uri, fileDoc.name))
}
fun importBook(uri: Uri) {
val book = LocalBook.importFile(uri)
openBookLiveData.postValue(book)
}
}
@@ -0,0 +1,284 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
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.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.DialogCustomGroupBinding
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onClick
/**
* 导入书源弹出窗口
*/
class ImportBookSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener,
CodeDialog.Callback {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportBookSourceViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_book_source)
//binding.rotateLoading.visible()
initMenu()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.import_source)
binding.toolBar.menu.findItem(R.id.menu_keep_original_name)
?.isChecked = AppConfig.importKeepName
binding.toolBar.menu.findItem(R.id.menu_keep_group)
?.isChecked = AppConfig.importKeepGroup
binding.toolBar.menu.findItem(R.id.menu_keep_enable)
?.isChecked = AppConfig.importKeepEnable
}
@SuppressLint("InflateParams", "NotifyDataSetChanged")
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_new_group -> alertCustomGroup(item)
R.id.menu_select_new_source -> {
val selectAllNew = viewModel.isSelectAllNew
viewModel.newSourceStatus.forEachIndexed { index, b ->
if (b) {
viewModel.selectStatus[index] = !selectAllNew
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
R.id.menu_select_update_source -> {
val selectAllUpdate = viewModel.isSelectAllUpdate
viewModel.updateSourceStatus.forEachIndexed { index, b ->
if (b) {
viewModel.selectStatus[index] = !selectAllUpdate
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
R.id.menu_keep_original_name -> {
item.isChecked = !item.isChecked
putPrefBoolean(PreferKey.importKeepName, item.isChecked)
}
R.id.menu_keep_group -> {
item.isChecked = !item.isChecked
putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
}
R.id.menu_keep_enable -> {
item.isChecked = !item.isChecked
AppConfig.importKeepEnable = item.isChecked
}
}
return false
}
private fun alertCustomGroup(item: MenuItem) {
alert(R.string.diy_edit_source_group) {
val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply {
val groups = appDb.bookSourceDao.allGroups()
textInputLayout.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView {
alertBinding.root
}
okButton {
viewModel.isAddGroup = alertBinding.swAddGroup.isChecked
viewModel.groupName = alertBinding.editView.text?.toString()
if (viewModel.groupName.isNullOrBlank()) {
item.title = getString(R.string.diy_source_group)
} else {
val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName)
if (viewModel.isAddGroup) {
item.title = "+$group"
} else {
item.title = group
}
}
}
cancelButton()
}
}
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
GSON.fromJsonObject<BookSource>(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<BookSource, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: BookSource,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.bookSourceName
val localSource = viewModel.checkSources[holder.layoutPosition]
tvSourceState.text = when {
localSource == null -> "新增"
item.lastUpdateTime > localSource.lastUpdateTime -> "更新"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
}
@@ -0,0 +1,221 @@
package io.legato.kazusa.ui.association
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import com.jayway.jsonpath.JsonPath
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.source.SourceHelp
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonArray
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.isUri
import io.legato.kazusa.utils.splitNotBlank
class ImportBookSourceViewModel(app: Application) : BaseViewModel(app) {
var isAddGroup = false
var groupName: String? = null
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<BookSource>()
val checkSources = arrayListOf<BookSourcePart?>()
val selectStatus = arrayListOf<Boolean>()
val newSourceStatus = arrayListOf<Boolean>()
val updateSourceStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val isSelectAllNew: Boolean
get() {
newSourceStatus.forEachIndexed { index, b ->
if (b && !selectStatus[index]) {
return false
}
}
return true
}
val isSelectAllUpdate: Boolean
get() {
updateSourceStatus.forEachIndexed { index, b ->
if (b && !selectStatus[index]) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val group = groupName?.trim()
val keepName = AppConfig.importKeepName
val keepGroup = AppConfig.importKeepGroup
val keepEnable = AppConfig.importKeepEnable
val selectSource = arrayListOf<BookSource>()
selectStatus.forEachIndexed { index, b ->
if (b) {
val source = allSources[index]
checkSources[index]?.let {
if (keepName) {
source.bookSourceName = it.bookSourceName
}
if (keepGroup) {
source.bookSourceGroup = it.bookSourceGroup
}
if (keepEnable) {
source.enabled = it.enabled
source.enabledExplore = it.enabledExplore
}
source.customOrder = it.customOrder
}
if (!group.isNullOrEmpty()) {
if (isAddGroup) {
val groups = linkedSetOf<String>()
source.bookSourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let {
groups.addAll(it)
}
groups.add(group)
source.bookSourceGroup = groups.joinToString(",")
} else {
source.bookSourceGroup = group
}
}
selectSource.add(source)
}
}
SourceHelp.insertBookSource(*selectSource.toTypedArray())
ContentProcessor.upReplaceRules()
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
val mText = text.trim()
when {
mText.isJsonObject() -> {
kotlin.runCatching {
val json = JsonPath.parse(mText)
json.read<List<String>>("$.sourceUrls")
}.onSuccess { listUrl ->
listUrl.forEach {
importSourceUrl(it)
}
}.onFailure {
GSON.fromJsonObject<BookSource>(mText).getOrThrow().let {
if (it.bookSourceUrl.isEmpty()) {
throw NoStackTraceException("不是书源")
}
allSources.add(it)
}
}
}
mText.isJsonArray() -> GSON.fromJsonArray<BookSource>(mText).getOrThrow()
.let { items ->
val source = items.firstOrNull() ?: return@let
if (source.bookSourceUrl.isEmpty()) {
throw NoStackTraceException("不是书源")
}
allSources.addAll(items)
}
mText.isAbsUrl() -> {
importSourceUrl(mText)
}
mText.isUri() -> {
val uri = Uri.parse(mText)
uri.inputStream(context).getOrThrow().use { inputS ->
GSON.fromJsonArray<BookSource>(inputS).getOrThrow().let {
val source = it.firstOrNull() ?: return@let
if (source.bookSourceUrl.isEmpty()) {
throw NoStackTraceException("不是书源")
}
allSources.addAll(it)
}
}
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().byteStream().use {
GSON.fromJsonArray<BookSource>(it).getOrThrow().let { list ->
val source = list.firstOrNull() ?: return@let
if (source.bookSourceUrl.isEmpty()) {
throw NoStackTraceException("不是书源")
}
allSources.addAll(list)
}
}
}
private fun comparisonSource() {
execute {
allSources.forEach {
val source = appDb.bookSourceDao.getBookSourcePart(it.bookSourceUrl)
checkSources.add(source)
selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime)
newSourceStatus.add(source == null)
updateSourceStatus.add(source != null && source.lastUpdateTime < it.lastUpdateTime)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,185 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
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.DictRule
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onClick
class ImportDictRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
CodeDialog.Callback {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportDictRuleViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_dict_rule)
//binding.rotateLoading.visible()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<DictRule, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: DictRule,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.name
val localSource = viewModel.checkSources[holder.layoutPosition]
tvSourceState.text = when (localSource) {
null -> "新增"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
GSON.fromJsonObject<DictRule>(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
}
}
}
@@ -0,0 +1,119 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.DictRule
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonArray
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
class ImportDictRuleViewModel(app: Application) : BaseViewModel(app) {
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<DictRule>()
val checkSources = arrayListOf<DictRule?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val selectSource = arrayListOf<DictRule>()
selectStatus.forEachIndexed { index, b ->
if (b) {
selectSource.add(allSources[index])
}
}
appDb.dictRuleDao.insert(*selectSource.toTypedArray())
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
importSourceAwait(text.trim())
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceAwait(text: String) {
when {
text.isJsonObject() -> {
GSON.fromJsonObject<DictRule>(text).getOrThrow().let {
allSources.add(it)
}
}
text.isJsonArray() -> GSON.fromJsonArray<DictRule>(text).getOrThrow().let { items ->
allSources.addAll(items)
}
text.isAbsUrl() -> {
importSourceUrl(text)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text().let {
importSourceAwait(it)
}
}
private fun comparisonSource() {
execute {
allSources.forEach {
val source = appDb.dictRuleDao.getByName(it.name)
checkSources.add(source)
selectStatus.add(source == null)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,189 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
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.HttpTTS
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import splitties.views.onClick
class ImportHttpTtsDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
CodeDialog.Callback {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportHttpTtsViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_tts)
//binding.rotateLoading.visible()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<HttpTTS, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: HttpTTS,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.name
val localSource = viewModel.checkSources[holder.layoutPosition]
tvSourceState.text = when {
localSource == null -> "新增"
item.lastUpdateTime > localSource.lastUpdateTime -> "更新"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
HttpTTS.fromJson(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
}
}
}
@@ -0,0 +1,116 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
class ImportHttpTtsViewModel(app: Application) : BaseViewModel(app) {
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<HttpTTS>()
val checkSources = arrayListOf<HttpTTS?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val selectSource = arrayListOf<HttpTTS>()
selectStatus.forEachIndexed { index, b ->
if (b) {
selectSource.add(allSources[index])
}
}
appDb.httpTTSDao.insert(*selectSource.toTypedArray())
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
importSourceAwait(text.trim())
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceAwait(text: String) {
when {
text.isJsonObject() -> {
HttpTTS.fromJson(text).getOrThrow().let {
allSources.add(it)
}
}
text.isJsonArray() -> HttpTTS.fromJsonArray(text).getOrThrow().let { items ->
allSources.addAll(items)
}
text.isAbsUrl() -> {
importSourceUrl(text)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text().let {
importSourceAwait(it)
}
}
private fun comparisonSource() {
execute {
allSources.forEach {
val source = appDb.httpTTSDao.get(it.id)
checkSources.add(source)
selectStatus.add(source == null || source.lastUpdateTime < it.lastUpdateTime)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,248 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
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.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.ReplaceRule
import io.legato.kazusa.databinding.DialogCustomGroupBinding
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onClick
class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener,
CodeDialog.Callback {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportReplaceRuleViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_replace_rule)
//binding.rotateLoading.visible()
initMenu()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
// binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allRules)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.import(source)
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.import_replace)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_new_group -> alertCustomGroup(item)
R.id.menu_keep_original_name -> {
item.isChecked = !item.isChecked
putPrefBoolean(PreferKey.importKeepName, item.isChecked)
}
}
return true
}
private fun alertCustomGroup(item: MenuItem) {
alert(R.string.diy_edit_source_group) {
val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply {
val groups = appDb.replaceRuleDao.allGroups()
textInputLayout.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView {
alertBinding.root
}
okButton {
viewModel.isAddGroup = alertBinding.swAddGroup.isChecked
viewModel.groupName = alertBinding.editView.text?.toString()
if (viewModel.groupName.isNullOrBlank()) {
item.title = getString(R.string.diy_source_group)
} else {
val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName)
if (viewModel.isAddGroup) {
item.title = "+$group"
} else {
item.title = group
}
}
}
noButton()
}
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allRules.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allRules.size
)
}
}
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
GSON.fromJsonObject<ReplaceRule>(code).getOrNull()?.let { rule ->
viewModel.allRules[it] = rule
adapter.setItem(it, rule)
}
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<ReplaceRule, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
@SuppressLint("SetTextI18n")
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: ReplaceRule,
payloads: MutableList<Any>
) {
binding.run {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = if (item.group.isNullOrBlank()) {
item.name
} else {
"${item.name}(${item.group})"
}
val localRule = viewModel.checkRules[holder.layoutPosition]
tvSourceState.text = when {
localRule == null -> "新增"
item.pattern != localRule.pattern
|| item.replacement != localRule.replacement
|| item.isRegex != localRule.isRegex
|| item.scope != localRule.scope -> "更新"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.run {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allRules[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
}
@@ -0,0 +1,133 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.ReplaceRule
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.ReplaceAnalyzer
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.splitNotBlank
class ImportReplaceRuleViewModel(app: Application) : BaseViewModel(app) {
var isAddGroup = false
var groupName: String? = null
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allRules = arrayListOf<ReplaceRule>()
val checkRules = arrayListOf<ReplaceRule?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val group = groupName?.trim()
val selectRules = arrayListOf<ReplaceRule>()
selectStatus.forEachIndexed { index, b ->
if (b) {
val rule = allRules[index]
if (!group.isNullOrEmpty()) {
if (isAddGroup) {
val groups = linkedSetOf<String>()
rule.group?.splitNotBlank(AppPattern.splitGroupRegex)?.let {
groups.addAll(it)
}
groups.add(group)
rule.group = groups.joinToString(",")
} else {
rule.group = group
}
}
selectRules.add(rule)
}
}
appDb.replaceRuleDao.insert(*selectRules.toTypedArray())
}.onFinally {
finally.invoke()
}
}
fun import(text: String) {
execute {
importAwait(text.trim())
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importAwait(text: String) {
when {
text.isAbsUrl() -> importUrl(text)
text.isJsonArray() -> {
val rules = ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow()
allRules.addAll(rules)
}
text.isJsonObject() -> {
val rule = ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow()
allRules.add(rule)
}
else -> throw NoStackTraceException("格式不对")
}
}
private suspend fun importUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text("utf-8").let {
importAwait(it)
}
}
private fun comparisonSource() {
execute {
allRules.forEach {
val rule = appDb.replaceRuleDao.findById(it.id)
checkRules.add(rule)
selectStatus.add(rule == null)
}
}.onSuccess {
successLiveData.postValue(allRules.size)
}
}
}
@@ -0,0 +1,261 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
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.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.databinding.DialogCustomGroupBinding
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.*
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.views.onClick
/**
* 导入rss源弹出窗口
*/
class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener,
CodeDialog.Callback {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportRssSourceViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_rss_source)
//binding.rotateLoading.visible()
initMenu()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.import_source)
binding.toolBar.menu.findItem(R.id.menu_keep_original_name)?.isChecked =
AppConfig.importKeepName
binding.toolBar.menu.findItem(R.id.menu_keep_group)?.isChecked =
AppConfig.importKeepGroup
binding.toolBar.menu.findItem(R.id.menu_keep_enable)?.isChecked =
AppConfig.importKeepEnable
binding.toolBar.menu.findItem(R.id.menu_select_new_source)?.isVisible = false
binding.toolBar.menu.findItem(R.id.menu_select_update_source)?.isVisible = false
}
@SuppressLint("InflateParams")
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_new_group -> alertCustomGroup(item)
R.id.menu_keep_original_name -> {
item.isChecked = !item.isChecked
putPrefBoolean(PreferKey.importKeepName, item.isChecked)
}
R.id.menu_keep_group -> {
item.isChecked = !item.isChecked
putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
}
R.id.menu_keep_enable -> {
item.isChecked = !item.isChecked
AppConfig.importKeepEnable = item.isChecked
}
}
return false
}
private fun alertCustomGroup(item: MenuItem) {
alert(R.string.diy_edit_source_group) {
val alertBinding = DialogCustomGroupBinding.inflate(layoutInflater).apply {
val groups = appDb.rssSourceDao.allGroups()
textInputLayout.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView {
alertBinding.root
}
okButton {
viewModel.isAddGroup = alertBinding.swAddGroup.isChecked
viewModel.groupName = alertBinding.editView.text?.toString()
if (viewModel.groupName.isNullOrBlank()) {
item.title = getString(R.string.diy_source_group)
} else {
val group = getString(R.string.diy_edit_source_group_title, viewModel.groupName)
if (viewModel.isAddGroup) {
item.title = "+$group"
} else {
item.title = group
}
}
}
cancelButton()
}
}
override fun onCodeSave(code: String, requestId: String?) {
requestId?.toInt()?.let {
GSON.fromJsonObject<RssSource>(code).getOrNull()?.let { source ->
viewModel.allSources[it] = source
adapter.setItem(it, source)
}
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<RssSource, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: RssSource,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.sourceName
tvSourceState.text = if (viewModel.checkSources[holder.layoutPosition] != null) {
"已有"
} else {
"新增"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
}
@@ -0,0 +1,181 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import com.jayway.jsonpath.JsonPath
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.RssSource
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.source.SourceHelp
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonArray
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
import io.legato.kazusa.utils.jsonPath
import io.legato.kazusa.utils.splitNotBlank
class ImportRssSourceViewModel(app: Application) : BaseViewModel(app) {
var isAddGroup = false
var groupName: String? = null
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<RssSource>()
val checkSources = arrayListOf<RssSource?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val group = groupName?.trim()
val keepName = AppConfig.importKeepName
val keepGroup = AppConfig.importKeepGroup
val keepEnable = AppConfig.importKeepEnable
val selectSource = arrayListOf<RssSource>()
selectStatus.forEachIndexed { index, b ->
if (b) {
val source = allSources[index]
checkSources[index]?.let {
if (keepName) {
source.sourceName = it.sourceName
}
if (keepGroup) {
source.sourceGroup = it.sourceGroup
}
if (keepEnable) {
source.enabled = it.enabled
}
source.customOrder = it.customOrder
}
if (!group.isNullOrEmpty()) {
if (isAddGroup) {
val groups = linkedSetOf<String>()
source.sourceGroup?.splitNotBlank(AppPattern.splitGroupRegex)?.let {
groups.addAll(it)
}
groups.add(group)
source.sourceGroup = groups.joinToString(",")
} else {
source.sourceGroup = group
}
}
selectSource.add(source)
}
}
SourceHelp.insertRssSource(*selectSource.toTypedArray())
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
val mText = text.trim()
when {
mText.isJsonObject() -> kotlin.runCatching {
val json = JsonPath.parse(mText)
val urls = json.read<List<String>>("$.sourceUrls")
if (!urls.isNullOrEmpty()) {
urls.forEach {
importSourceUrl(it)
}
}
}.onFailure {
GSON.fromJsonArray<RssSource>(mText).getOrThrow().let {
val source = it.firstOrNull() ?: return@let
if (source.sourceUrl.isEmpty()) {
throw NoStackTraceException("不是订阅源")
}
allSources.addAll(it)
}
}
mText.isJsonArray() -> {
GSON.fromJsonArray<RssSource>(mText).getOrThrow().let {
val source = it.firstOrNull() ?: return@let
if (source.sourceUrl.isEmpty()) {
throw NoStackTraceException("不是订阅源")
}
allSources.addAll(it)
}
}
mText.isAbsUrl() -> {
importSourceUrl(mText)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().byteStream().use { body ->
val items: List<Map<String, Any>> = jsonPath.parse(body).read("$")
for (item in items) {
if (!item.containsKey("sourceUrl")) {
throw NoStackTraceException("不是订阅源")
}
val jsonItem = jsonPath.parse(item)
GSON.fromJsonObject<RssSource>(jsonItem.jsonString()).getOrThrow().let { source ->
allSources.add(source)
}
}
}
}
private fun comparisonSource() {
execute {
allSources.forEach {
val has = appDb.rssSourceDao.getByKey(it.sourceUrl)
checkSources.add(has)
selectStatus.add(has == null)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,179 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
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.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
import io.legato.kazusa.help.config.ThemeConfig
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import splitties.views.onClick
class ImportThemeDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view) {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportThemeViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_theme)
//binding.rotateLoading.visible()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<ThemeConfig.Config, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: ThemeConfig.Config,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.themeName
val localSource = viewModel.checkSources[holder.layoutPosition]
tvSourceState.text = when {
localSource == null -> "新增"
localSource != item -> "更新"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
}
@@ -0,0 +1,114 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.config.ThemeConfig
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.*
class ImportThemeViewModel(app: Application) : BaseViewModel(app) {
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<ThemeConfig.Config>()
val checkSources = arrayListOf<ThemeConfig.Config?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
selectStatus.forEachIndexed { index, b ->
if (b) {
ThemeConfig.addConfig(allSources[index])
}
}
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
importSourceAwait(text.trim())
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceAwait(text: String) {
when {
text.isJsonObject() -> {
GSON.fromJsonObject<ThemeConfig.Config>(text).getOrThrow().let {
allSources.add(it)
}
}
text.isJsonArray() -> GSON.fromJsonArray<ThemeConfig.Config>(text).getOrThrow()
.let { items ->
allSources.addAll(items)
}
text.isAbsUrl() -> {
importSourceUrl(text)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text().let {
importSourceAwait(it)
}
}
private fun comparisonSource() {
execute {
allSources.forEach { config ->
val source = ThemeConfig.configList.find {
it.themeName == config.themeName
}
checkSources.add(source)
selectStatus.add(source == null || source != config)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,179 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
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.TxtTocRule
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemSourceImportBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.widget.dialog.CodeDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import splitties.views.onClick
class ImportTxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_view) {
constructor(source: String, finishOnDismiss: Boolean = false) : this() {
arguments = Bundle().apply {
putString("source", source)
putBoolean("finishOnDismiss", finishOnDismiss)
}
}
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by viewModels<ImportTxtTocRuleViewModel>()
private val adapter by lazy { SourcesAdapter(requireContext()) }
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (arguments?.getBoolean("finishOnDismiss") == true) {
activity?.finish()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.import_txt_toc_rule)
//binding.rotateLoading.visible()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
val waitDialog = WaitDialog(requireContext())
waitDialog.show()
viewModel.importSelect {
waitDialog.dismiss()
dismissAllowingStateLoss()
}
}
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
val selectAll = viewModel.isSelectAll
viewModel.selectStatus.forEachIndexed { index, b ->
if (b != !selectAll) {
viewModel.selectStatus[index] = !selectAll
}
}
adapter.notifyDataSetChanged()
upSelectText()
}
viewModel.errorLiveData.observe(this) {
//binding.rotateLoading.gone()
binding.tvMsg.apply {
text = it
visible()
}
}
viewModel.successLiveData.observe(this) {
//binding.rotateLoading.gone()
if (it > 0) {
adapter.setItems(viewModel.allSources)
upSelectText()
} else {
binding.tvMsg.apply {
setText(R.string.wrong_format)
visible()
}
}
}
val source = arguments?.getString("source")
if (source.isNullOrEmpty()) {
dismiss()
return
}
viewModel.importSource(source)
}
private fun upSelectText() {
if (viewModel.isSelectAll) {
binding.tvFooterLeft.text = getString(
R.string.select_cancel_count,
viewModel.selectCount,
viewModel.allSources.size
)
} else {
binding.tvFooterLeft.text = getString(
R.string.select_all_count,
viewModel.selectCount,
viewModel.allSources.size
)
}
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<TxtTocRule, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: TxtTocRule,
payloads: MutableList<Any>
) {
binding.apply {
cbSourceName.isChecked = viewModel.selectStatus[holder.layoutPosition]
cbSourceName.text = item.name
val localSource = viewModel.checkSources[holder.layoutPosition]
tvSourceState.text = when {
localSource == null -> "新增"
item != localSource -> "更新"
else -> "已有"
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSourceImportBinding) {
binding.apply {
cbSourceName.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
viewModel.selectStatus[holder.layoutPosition] = isChecked
upSelectText()
}
}
root.onClick {
cbSourceName.isChecked = !cbSourceName.isChecked
viewModel.selectStatus[holder.layoutPosition] = cbSourceName.isChecked
upSelectText()
}
tvOpen.setOnClickListener {
val source = viewModel.allSources[holder.layoutPosition]
showDialogFragment(
CodeDialog(
GSON.toJson(source),
disableEdit = false,
requestId = holder.layoutPosition.toString()
)
)
}
}
}
}
}
@@ -0,0 +1,120 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.TxtTocRule
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonArray
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.isJsonArray
import io.legato.kazusa.utils.isJsonObject
class ImportTxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<TxtTocRule>()
val checkSources = arrayListOf<TxtTocRule?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
get() {
selectStatus.forEach {
if (!it) {
return false
}
}
return true
}
val selectCount: Int
get() {
var count = 0
selectStatus.forEach {
if (it) {
count++
}
}
return count
}
fun importSelect(finally: () -> Unit) {
execute {
val selectSource = arrayListOf<TxtTocRule>()
selectStatus.forEachIndexed { index, b ->
if (b) {
selectSource.add(allSources[index])
}
}
appDb.txtTocRuleDao.insert(*selectSource.toTypedArray())
}.onFinally {
finally.invoke()
}
}
fun importSource(text: String) {
execute {
importSourceAwait(text.trim())
}.onError {
errorLiveData.postValue("ImportError:${it.localizedMessage}")
AppLog.put("ImportError:${it.localizedMessage}", it)
}.onSuccess {
comparisonSource()
}
}
private suspend fun importSourceAwait(text: String) {
when {
text.isJsonObject() -> {
GSON.fromJsonObject<TxtTocRule>(text).getOrThrow().let {
allSources.add(it)
}
}
text.isJsonArray() -> GSON.fromJsonArray<TxtTocRule>(text).getOrThrow()
.let { items ->
allSources.addAll(items)
}
text.isAbsUrl() -> {
importSourceUrl(text)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
private suspend fun importSourceUrl(url: String) {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text().let {
importSourceAwait(it)
}
}
private fun comparisonSource() {
execute {
allSources.forEach {
val source = appDb.txtTocRuleDao.get(it.id)
checkSources.add(source)
selectStatus.add(source == null || it != source)
}
successLiveData.postValue(allSources.size)
}
}
}
@@ -0,0 +1,115 @@
package io.legato.kazusa.ui.association
import android.os.Bundle
import androidx.activity.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.databinding.ActivityTranslucenceBinding
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 网络一键导入
* 格式: legado://import/{path}?src={url}
*/
class OnLineImportActivity :
VMBaseActivity<ActivityTranslucenceBinding, OnLineImportViewModel>() {
override val binding by viewBinding(ActivityTranslucenceBinding::inflate)
override val viewModel by viewModels<OnLineImportViewModel>()
override fun onActivityCreated(savedInstanceState: Bundle?) {
viewModel.successLive.observe(this) {
when (it.first) {
"bookSource" -> showDialogFragment(
ImportBookSourceDialog(it.second, true)
)
"rssSource" -> showDialogFragment(
ImportRssSourceDialog(it.second, true)
)
"replaceRule" -> showDialogFragment(
ImportReplaceRuleDialog(it.second, true)
)
"httpTts" -> showDialogFragment(
ImportHttpTtsDialog(it.second, true)
)
"theme" -> showDialogFragment(
ImportThemeDialog(it.second, true)
)
"txtRule" -> showDialogFragment(
ImportTxtTocRuleDialog(it.second, true)
)
"dictRule" -> showDialogFragment(
ImportDictRuleDialog(it.second, true)
)
}
}
viewModel.errorLive.observe(this) {
finallyDialog(getString(R.string.error), it)
}
intent.data?.let {
val url = it.getQueryParameter("src")
if (url.isNullOrEmpty()) {
finish()
return
}
when (it.path) {
"/bookSource" -> showDialogFragment(
ImportBookSourceDialog(url, true)
)
"/rssSource" -> showDialogFragment(
ImportRssSourceDialog(url, true)
)
"/replaceRule" -> showDialogFragment(
ImportReplaceRuleDialog(url, true)
)
"/textTocRule" -> showDialogFragment(
ImportTxtTocRuleDialog(url, true)
)
"/httpTTS" -> showDialogFragment(
ImportHttpTtsDialog(url, true)
)
"/dictRule" -> showDialogFragment(
ImportDictRuleDialog(url, true)
)
"/theme" -> showDialogFragment(
ImportThemeDialog(url, true)
)
"/readConfig" -> viewModel.getBytes(url) { bytes ->
viewModel.importReadConfig(bytes, this::finallyDialog)
}
"/addToBookshelf" -> showDialogFragment(
AddToBookshelfDialog(url, true)
)
"/importonline" -> when (it.host) {
"booksource" -> showDialogFragment(
ImportBookSourceDialog(url, true)
)
"rsssource" -> showDialogFragment(
ImportRssSourceDialog(url, true)
)
"replace" -> showDialogFragment(
ImportReplaceRuleDialog(url, true)
)
else -> {
viewModel.determineType(url, this::finallyDialog)
}
}
else -> viewModel.determineType(url, this::finallyDialog)
}
}
}
private fun finallyDialog(title: String, msg: String) {
alert(title, msg) {
okButton()
onDismiss {
finish()
}
}
}
}
@@ -0,0 +1,111 @@
package io.legato.kazusa.ui.association
import android.app.Application
import androidx.core.net.toUri
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.help.http.decompressed
import io.legato.kazusa.help.http.newCallResponseBody
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.help.http.text
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.externalCache
import okhttp3.MediaType.Companion.toMediaType
import splitties.init.appCtx
class OnLineImportViewModel(app: Application) : BaseAssociationViewModel(app) {
fun getText(url: String, success: (text: String) -> Unit) {
execute {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.decompressed().text("utf-8")
}.onSuccess {
success.invoke(it)
}.onError {
errorLive.postValue(
it.localizedMessage ?: context.getString(R.string.unknown_error)
)
}
}
fun getBytes(url: String, success: (bytes: ByteArray) -> Unit) {
execute {
okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}.bytes()
}.onSuccess {
success.invoke(it)
}.onError {
errorLive.postValue(
it.localizedMessage ?: context.getString(R.string.unknown_error)
)
}
}
fun importReadConfig(bytes: ByteArray, finally: (title: String, msg: String) -> Unit) {
execute {
val config = ReadBookConfig.import(bytes).getOrThrow()
ReadBookConfig.configList.forEachIndexed { index, c ->
if (c.name == config.name) {
ReadBookConfig.configList[index] = config
return@execute config.name
}
ReadBookConfig.configList.add(config)
return@execute config.name
}
}.onSuccess {
finally.invoke(context.getString(R.string.success), "导入排版成功")
}.onError {
finally.invoke(
context.getString(R.string.error),
it.localizedMessage ?: context.getString(R.string.unknown_error)
)
}
}
fun determineType(url: String, finally: (title: String, msg: String) -> Unit) {
execute {
val rs = okHttpClient.newCallResponseBody {
if (url.endsWith("#requestWithoutUA")) {
url(url.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(url)
}
}
when (rs.contentType()) {
"application/zip".toMediaType(),
"application/octet-stream".toMediaType() -> {
importReadConfig(rs.bytes(), finally)
}
else -> {
val inputStream = rs.byteStream()
val file = FileUtils.createFileIfNotExist(
appCtx.externalCache,
"download",
"scheme_import_cache.json"
)
file.outputStream().use { out ->
inputStream.use {
it.copyTo(out)
}
}
importJson(file.toUri())
}
}
}
}
}
@@ -0,0 +1,25 @@
package io.legato.kazusa.ui.association
import android.os.Bundle
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.constant.SourceType
import io.legato.kazusa.databinding.ActivityTranslucenceBinding
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class OpenUrlConfirmActivity :
BaseActivity<ActivityTranslucenceBinding>() {
override val binding by viewBinding(ActivityTranslucenceBinding::inflate)
override fun onActivityCreated(savedInstanceState: Bundle?) {
intent.getStringExtra("uri")?.let {
val mimeType = intent.getStringExtra("mimeType")
val sourceOrigin = intent.getStringExtra("sourceOrigin")
val sourceName = intent.getStringExtra("sourceName")
val sourceType = intent.getIntExtra("sourceType", SourceType.book)
showDialogFragment(OpenUrlConfirmDialog(it, mimeType, sourceOrigin, sourceName, sourceType))
} ?: finish()
}
}
@@ -0,0 +1,132 @@
package io.legato.kazusa.ui.association
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.databinding.DialogOpenUrlConfirmBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.init.appCtx
class OpenUrlConfirmDialog() : BaseDialogFragment(R.layout.dialog_open_url_confirm),
Toolbar.OnMenuItemClickListener {
constructor(
uri: String,
mimeType: String?,
sourceOrigin: String? = null,
sourceName: String? = null,
sourceType: Int
) : this() {
arguments = Bundle().apply {
putString("uri", uri)
putString("mimeType", mimeType)
putString("sourceOrigin", sourceOrigin)
putString("sourceName", sourceName)
putInt("sourceType", sourceType)
}
}
val binding by viewBinding(DialogOpenUrlConfirmBinding::bind)
val viewModel by viewModels<OpenUrlConfirmViewModel>()
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initMenu()
val arguments = arguments ?: return
viewModel.initData(arguments)
if (viewModel.uri.isBlank()) {
dismiss()
return
}
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.subtitle = viewModel.sourceName
initView()
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.open_url_confirm)
binding.toolBar.menu.applyTint(requireContext())
}
private fun initView() {
binding.message.text = "${viewModel.sourceName} 正在请求跳转链接/应用,是否跳转?"
binding.btnNegative.setOnClickListener { dismiss() }
binding.btnPositive.setOnClickListener {
openUrl()
dismiss()
}
}
private fun openUrl() {
try {
val uri = viewModel.uri.toUri()
val mimeType = viewModel.mimeType
// 创建目标 Intent 并设置类型
val targetIntent = Intent(Intent.ACTION_VIEW).apply {
// 同时设置 Data 和 Type
if (!mimeType.isNullOrBlank()) {
setDataAndType(uri, mimeType)
} else {
data = uri
}
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// 验证是否有应用可以处理
if (targetIntent.resolveActivity(appCtx.packageManager) != null) {
startActivity(targetIntent)
} else {
toastOnUi(R.string.can_not_open)
}
} catch (e: Exception) {
AppLog.put("打开链接失败", e, true)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_disable_source -> {
viewModel.disableSource {
dismiss()
}
}
R.id.menu_delete_source -> {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName)
noButton()
yesButton {
viewModel.deleteSource {
dismiss()
}
}
}
}
}
return false
}
override fun onDestroy() {
super.onDestroy()
activity?.finish()
}
}
@@ -0,0 +1,41 @@
package io.legato.kazusa.ui.association
import android.app.Application
import android.os.Bundle
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.SourceType
import io.legato.kazusa.help.source.SourceHelp
class OpenUrlConfirmViewModel(app: Application): BaseViewModel(app) {
var uri = ""
var mimeType: String? = null
var sourceOrigin = ""
var sourceName = ""
var sourceType = SourceType.book
fun initData(arguments: Bundle) {
uri = arguments.getString("uri") ?: ""
mimeType = arguments.getString("mimeType")
sourceName = arguments.getString("sourceName") ?: ""
sourceOrigin = arguments.getString("sourceOrigin") ?: ""
sourceType = arguments.getInt("sourceType", SourceType.book)
}
fun disableSource(block: () -> Unit) {
execute {
SourceHelp.enableSource(sourceOrigin, sourceType, false)
}.onSuccess {
block.invoke()
}
}
fun deleteSource(block: () -> Unit) {
execute {
SourceHelp.deleteSource(sourceOrigin, sourceType)
}.onSuccess {
block.invoke()
}
}
}
@@ -0,0 +1,29 @@
package io.legato.kazusa.ui.association
import android.os.Bundle
import io.legato.kazusa.base.BaseActivity
import io.legato.kazusa.constant.SourceType
import io.legato.kazusa.databinding.ActivityTranslucenceBinding
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 验证码
*/
class VerificationCodeActivity :
BaseActivity<ActivityTranslucenceBinding>() {
override val binding by viewBinding(ActivityTranslucenceBinding::inflate)
override fun onActivityCreated(savedInstanceState: Bundle?) {
intent.getStringExtra("imageUrl")?.let {
val sourceOrigin = intent.getStringExtra("sourceOrigin")
val sourceName = intent.getStringExtra("sourceName")
val sourceType = intent.getIntExtra("sourceType", SourceType.book)
showDialogFragment(
VerificationCodeDialog(it, sourceOrigin, sourceName, sourceType)
)
} ?: finish()
}
}
@@ -0,0 +1,156 @@
package io.legato.kazusa.ui.association
import android.annotation.SuppressLint
import android.graphics.Bitmap
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 com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
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.BaseDialogFragment
import io.legato.kazusa.databinding.DialogVerificationCodeViewBinding
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.help.glide.OkHttpModelLoader
import io.legato.kazusa.help.source.SourceVerificationHelp
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.model.ImageProvider
import io.legato.kazusa.ui.widget.dialog.PhotoDialog
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
/**
* 图片验证码对话框
* 结果保存在内存中
* val key = "${sourceOrigin ?: ""}_verificationResult"
* CacheManager.get(key)
*/
class VerificationCodeDialog() : BaseDialogFragment(R.layout.dialog_verification_code_view),
Toolbar.OnMenuItemClickListener {
constructor(
imageUrl: String,
sourceOrigin: String? = null,
sourceName: String? = null,
sourceType: Int
) : this() {
arguments = Bundle().apply {
putString("imageUrl", imageUrl)
putString("sourceOrigin", sourceOrigin)
putString("sourceName", sourceName)
putInt("sourceType", sourceType)
}
}
val binding by viewBinding(DialogVerificationCodeViewBinding::bind)
val viewModel by viewModels<VerificationCodeViewModel>()
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
private var sourceOrigin: String? = null
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run {
initMenu()
val arguments = arguments ?: return@run
viewModel.initData(arguments)
//toolBar.setBackgroundColor(primaryColor)
toolBar.subtitle = arguments.getString("sourceName")
sourceOrigin = arguments.getString("sourceOrigin")
val imageUrl = arguments.getString("imageUrl") ?: return@run
loadImage(imageUrl, sourceOrigin)
verificationCodeImageView.setOnClickListener {
showDialogFragment(PhotoDialog(imageUrl, sourceOrigin))
}
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.verification_code)
binding.toolBar.menu.applyTint(requireContext())
}
@SuppressLint("CheckResult")
private fun loadImage(url: String, sourceUrl: String?) {
ImageProvider.remove(url)
ImageLoader.loadBitmap(requireContext(), url).apply {
sourceUrl?.let {
apply(RequestOptions().set(OkHttpModelLoader.sourceOriginOption, it))
}
}.error(R.drawable.image_loading_error)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.listener(object : RequestListener<Bitmap> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Bitmap?>,
isFirstResource: Boolean
): Boolean {
return false
}
override fun onResourceReady(
resource: Bitmap,
model: Any,
target: Target<Bitmap?>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
val bitmap = resource.copy(resource.config!!, true)
ImageProvider.put(url, bitmap) // 传给 PhotoDialog
return false
}
})
.into(binding.verificationCodeImageView)
}
@SuppressLint("InflateParams")
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_ok -> {
val verificationCode = binding.verificationCode.text.toString()
SourceVerificationHelp.setResult(sourceOrigin!!, verificationCode)
dismiss()
}
R.id.menu_disable_source -> {
viewModel.disableSource {
dismiss()
}
}
R.id.menu_delete_source -> {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName)
noButton()
yesButton {
viewModel.deleteSource {
dismiss()
}
}
}
}
}
return false
}
override fun onDestroy() {
SourceVerificationHelp.checkResult(sourceOrigin!!)
super.onDestroy()
activity?.finish()
}
}
@@ -0,0 +1,37 @@
package io.legato.kazusa.ui.association
import android.app.Application
import android.os.Bundle
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.SourceType
import io.legato.kazusa.help.source.SourceHelp
class VerificationCodeViewModel(app: Application): BaseViewModel(app) {
var sourceOrigin = ""
var sourceName = ""
private var sourceType = SourceType.book
fun initData(arguments: Bundle) {
sourceName = arguments.getString("sourceName") ?: ""
sourceOrigin = arguments.getString("sourceOrigin") ?: ""
sourceType = arguments.getInt("sourceType", SourceType.book)
}
fun disableSource(block: () -> Unit) {
execute {
SourceHelp.enableSource(sourceOrigin, sourceType, false)
}.onSuccess {
block.invoke()
}
}
fun deleteSource(block: () -> Unit) {
execute {
SourceHelp.deleteSource(sourceOrigin, sourceType)
}.onSuccess {
block.invoke()
}
}
}
@@ -0,0 +1,339 @@
package io.legato.kazusa.ui.book.audio
import android.annotation.SuppressLint
import android.icu.text.SimpleDateFormat
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.slider.Slider
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.Status
import io.legato.kazusa.constant.Theme
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityAudioPlayBinding
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.service.AudioPlayService
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.observeEventSticky
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.views.onLongClick
import java.util.Locale
/**
* 音频播放
*/
@SuppressLint("ObsoleteSdkInt")
class AudioPlayActivity :
VMBaseActivity<ActivityAudioPlayBinding, AudioPlayViewModel>(toolBarTheme = Theme.Dark),
ChangeBookSourceDialog.CallBack,
AudioPlay.CallBack {
override val binding by viewBinding(ActivityAudioPlayBinding::inflate)
override val viewModel by viewModels<AudioPlayViewModel>()
private val timerSliderPopup by lazy { TimerSliderPopup(this) }
private var adjustProgress = false
private val progressTimeFormat by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
SimpleDateFormat("mm:ss", Locale.getDefault())
} else {
java.text.SimpleDateFormat("mm:ss", Locale.getDefault())
}
}
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
it?.let {
if (it.first != AudioPlay.book?.durChapterIndex
|| it.second == 0
) {
AudioPlay.skipTo(it.first)
}
}
}
private val sourceEditResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
viewModel.upSource()
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.titleBar.setBackgroundResource(R.color.transparent)
AudioPlay.register(this)
viewModel.titleData.observe(this) {
binding.titleBar.title = it
}
viewModel.coverData.observe(this) {
upCover(it)
}
viewModel.initData(intent)
initView()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.audio_play, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_login)?.isVisible = !AudioPlay.bookSource?.loginUrl.isNullOrBlank()
menu.findItem(R.id.menu_wake_lock)?.isChecked = AppConfig.audioPlayUseWakeLock
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_change_source -> AudioPlay.book?.let {
showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
}
R.id.menu_login -> AudioPlay.bookSource?.let {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", it.bookSourceUrl)
}
}
R.id.menu_wake_lock -> AppConfig.audioPlayUseWakeLock = !AppConfig.audioPlayUseWakeLock
R.id.menu_copy_audio_url -> sendToClip(AudioPlayService.url)
R.id.menu_edit_source -> AudioPlay.bookSource?.let {
sourceEditResult.launch {
putExtra("sourceUrl", it.bookSourceUrl)
}
}
R.id.menu_log -> showDialogFragment<AppLogDialog>()
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.fabPlayStop.setOnClickListener {
playButton()
}
binding.fabPlayStop.onLongClick {
AudioPlay.stop()
}
binding.ivSkipNext.setOnClickListener {
AudioPlay.next()
}
binding.ivSkipPrevious.setOnClickListener {
AudioPlay.prev()
}
// binding.playerProgress.setOnSeekBarChangeListener(object : SeekBarChangeListener {
// override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
// binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
// }
//
// override fun onStartTrackingTouch(seekBar: SeekBar) {
// adjustProgress = true
// }
//
// override fun onStopTrackingTouch(seekBar: SeekBar) {
// adjustProgress = false
// AudioPlay.adjustProgress(seekBar.progress)
// }
// })
// 替换原来的 SeekBar 监听器
binding.playerProgress.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
binding.tvDurTime.text = progressTimeFormat.format(value.toLong())
}
}
binding.playerProgress.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
adjustProgress = true
}
override fun onStopTrackingTouch(slider: Slider) {
adjustProgress = false
AudioPlay.adjustProgress(slider.value.toInt())
}
})
binding.ivChapter.setOnClickListener {
AudioPlay.book?.let {
tocActivityResult.launch(it.bookUrl)
}
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
binding.ivFastRewind.invisible()
binding.ivFastForward.invisible()
}
binding.ivFastForward.setOnClickListener {
AudioPlay.adjustSpeed(0.1f)
}
binding.ivFastRewind.setOnClickListener {
AudioPlay.adjustSpeed(-0.1f)
}
binding.ivTimer.setOnClickListener {
timerSliderPopup.showAsDropDown(it, 0, (-100).dpToPx(), Gravity.TOP)
}
binding.llPlayMenu.applyNavigationBarPadding()
}
private fun upCover(path: String?) {
BookCover.load(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl) {
BookCover.loadBlur(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl)
.into(binding.ivBg)
}.into(binding.ivCover)
}
private fun playButton() {
when (AudioPlay.status) {
Status.PLAY -> AudioPlay.pause(this)
Status.PAUSE -> AudioPlay.resume(this)
else -> AudioPlay.loadOrUpPlayUrl()
}
}
override val oldBook: Book?
get() = AudioPlay.book
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.isAudio) {
viewModel.changeTo(source, book, toc)
} else {
AudioPlay.stop()
lifecycleScope.launch {
withContext(IO) {
AudioPlay.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
}
startActivityForBook(book)
finish()
}
}
}
override fun finish() {
val book = AudioPlay.book ?: return super.finish()
if (AudioPlay.inBookshelf) {
return super.finish()
}
if (!AppConfig.showAddToShelfAlert) {
viewModel.removeFromBookshelf { super.finish() }
} else {
alert(title = getString(R.string.add_to_bookshelf)) {
setMessage(getString(R.string.check_add_bookshelf, book.name))
okButton {
AudioPlay.book?.removeType(BookType.notShelf)
AudioPlay.book?.save()
AudioPlay.inBookshelf = true
setResult(RESULT_OK)
}
noButton { viewModel.removeFromBookshelf { super.finish() } }
}
}
}
override fun onDestroy() {
super.onDestroy()
if (AudioPlay.status != Status.PLAY) {
AudioPlay.stop()
}
AudioPlay.unregister(this)
}
@SuppressLint("SetTextI18n")
override fun observeLiveBus() {
observeEvent<Boolean>(EventBus.MEDIA_BUTTON) {
if (it) {
playButton()
}
}
observeEventSticky<Int>(EventBus.AUDIO_STATE) {
AudioPlay.status = it
if (it == Status.PLAY) {
binding.fabPlayStop.setImageResource(R.drawable.ic_pause_24dp)
} else {
binding.fabPlayStop.setImageResource(R.drawable.ic_play_24dp)
}
}
observeEventSticky<String>(EventBus.AUDIO_SUB_TITLE) {
binding.tvSubTitle.text = it
binding.ivSkipPrevious.isEnabled = AudioPlay.durChapterIndex > 0
binding.ivSkipNext.isEnabled =
AudioPlay.durChapterIndex < AudioPlay.simulatedChapterSize - 1
}
// observeEventSticky<Int>(EventBus.AUDIO_SIZE) {
// binding.playerProgress.max = it
// binding.tvAllTime.text = progressTimeFormat.format(it.toLong())
// }
// observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) {
// if (!adjustProgress) binding.playerProgress.progress = it
// binding.tvDurTime.text = progressTimeFormat.format(it.toLong())
// }
// observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
// binding.playerProgress.secondaryProgress = it
//
// }
observeEventSticky<Int>(EventBus.AUDIO_SIZE) {
binding.playerProgress.valueTo = it.toFloat()
binding.tvAllTime.text = progressTimeFormat.format(it.toLong())
}
observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) {
if (!adjustProgress) binding.playerProgress.value = it.toFloat()
binding.tvDurTime.text = progressTimeFormat.format(it.toLong())
}
observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
//updateBufferProgress(it)
}
observeEventSticky<Float>(EventBus.AUDIO_SPEED) {
binding.tvSpeed.text = String.format(Locale.ROOT, "%.1fX", it)
binding.tvSpeed.visible()
}
observeEventSticky<Int>(EventBus.AUDIO_DS) {
binding.tvTimer.text = "${it}m"
binding.tvTimer.visible(it > 0)
}
}
override fun upLoading(loading: Boolean) {
runOnUiThread {
binding.progressLoading.visible(loading)
}
}
}
@@ -0,0 +1,120 @@
package io.legato.kazusa.ui.book.audio
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.help.book.getBookSource
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.simulatedTotalChapterNum
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
class AudioPlayViewModel(application: Application) : BaseViewModel(application) {
val titleData = MutableLiveData<String>()
val coverData = MutableLiveData<String>()
fun initData(intent: Intent) = AudioPlay.apply {
execute {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@execute
val book = appDb.bookDao.getBook(bookUrl) ?: return@execute
inBookshelf = intent.getBooleanExtra("inBookshelf", true)
initBook(book)
}.onFinally {
saveRead()
}
}
private suspend fun initBook(book: Book) {
val isSameBook = AudioPlay.book?.bookUrl == book.bookUrl
if (isSameBook) {
AudioPlay.upData(book)
} else {
AudioPlay.resetData(book)
}
titleData.postValue(book.name)
coverData.postValue(book.getDisplayCover())
if (book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (AudioPlay.chapterSize == 0 && !loadChapterList(book)) {
return
}
}
private suspend fun loadBookInfo(book: Book): Boolean {
val bookSource = AudioPlay.bookSource ?: return true
try {
WebBook.getBookInfoAwait(bookSource, book)
return true
} catch (e: Exception) {
AppLog.put("详情页出错: ${e.localizedMessage}", e, true)
return false
}
}
private suspend fun loadChapterList(book: Book): Boolean {
val bookSource = AudioPlay.bookSource ?: return true
try {
val oldBook = book.copy()
val cList = WebBook.getChapterListAwait(bookSource, book).getOrThrow()
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
}
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
AudioPlay.chapterSize = cList.size
AudioPlay.simulatedChapterSize = book.simulatedTotalChapterNum()
AudioPlay.upDurChapter()
return true
} catch (e: Exception) {
context.toastOnUi(R.string.error_load_toc)
return false
}
}
fun upSource() {
execute {
val book = AudioPlay.book ?: return@execute
AudioPlay.bookSource = book.getBookSource()
}
}
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
execute {
AudioPlay.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
AudioPlay.book = book
AudioPlay.bookSource = source
appDb.bookChapterDao.insert(*toc.toTypedArray())
AudioPlay.upDurChapter()
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
fun removeFromBookshelf(success: (() -> Unit)?) {
execute {
AudioPlay.book?.let {
appDb.bookDao.delete(it)
}
}.onSuccess {
success?.invoke()
}
}
}
@@ -0,0 +1,53 @@
package io.legato.kazusa.ui.book.audio
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import io.legato.kazusa.R
import io.legato.kazusa.databinding.PopupSeekBarBinding
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.service.AudioPlayService
class TimerSliderPopup(private val context: Context) :
PopupWindow(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
private val binding = PopupSeekBarBinding.inflate(LayoutInflater.from(context))
init {
contentView = binding.root
isTouchable = true
isOutsideTouchable = false
isFocusable = true
// 初始化 slider 值
binding.slider.valueFrom = 1f
binding.slider.valueTo = 180f
binding.slider.stepSize = 1f
binding.slider.value = AudioPlayService.timeMinute.toFloat()
setSliderTextValue(AudioPlayService.timeMinute)
binding.slider.addOnChangeListener { _, value, fromUser ->
setSliderTextValue(value.toInt())
if (fromUser) {
AudioPlay.setTimer(value.toInt())
}
}
}
override fun showAsDropDown(anchor: View?, xoff: Int, yoff: Int, gravity: Int) {
super.showAsDropDown(anchor, xoff, yoff, gravity)
binding.slider.value = AudioPlayService.timeMinute.toFloat()
}
override fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) {
super.showAtLocation(parent, gravity, x, y)
binding.slider.value = AudioPlayService.timeMinute.toFloat()
}
private fun setSliderTextValue(value: Int) {
binding.tvSeekValue.text = context.getString(R.string.timer_m, value)
}
}
@@ -0,0 +1,82 @@
package io.legato.kazusa.ui.book.bookmark
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
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.Bookmark
import io.legato.kazusa.databinding.ActivityAllBookmarkBinding
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.showDialogFragment
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 AllBookmarkActivity : VMBaseActivity<ActivityAllBookmarkBinding, AllBookmarkViewModel>(),
BookmarkAdapter.Callback {
override val viewModel by viewModels<AllBookmarkViewModel>()
override val binding by viewBinding(ActivityAllBookmarkBinding::inflate)
private val adapter by lazy {
BookmarkAdapter(this, this)
}
private val exportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
when (it.requestCode) {
1 -> viewModel.exportBookmark(uri)
2 -> viewModel.exportBookmarkMd(uri)
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
initView()
lifecycleScope.launch {
appDb.bookmarkDao.flowAll().catch {
AppLog.put("所有书签界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun initView() {
binding.recyclerView.addItemDecoration(BookmarkDecoration(this,adapter))
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bookmark, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_export -> exportDir.launch {
requestCode = 1
}
R.id.menu_export_md -> exportDir.launch {
requestCode = 2
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onItemClick(bookmark: Bookmark, position: Int) {
showDialogFragment(BookmarkDialog(bookmark, position))
}
}
@@ -0,0 +1,67 @@
package io.legato.kazusa.ui.book.bookmark
import android.app.Application
import android.net.Uri
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.createFileIfNotExist
import io.legato.kazusa.utils.openOutputStream
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeToOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class AllBookmarkViewModel(application: Application) : BaseViewModel(application) {
/**
* 导出书签
*/
fun exportBookmark(treeUri: Uri) {
execute {
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val fileName = "bookmark-${dateFormat.format(Date())}.json"
val dirDoc = FileDoc.fromUri(treeUri, true)
dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow().use {
GSON.writeToOutputStream(it, appDb.bookmarkDao.all)
}
}.onError {
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
}.onSuccess {
context.toastOnUi("导出成功")
}
}
fun exportBookmarkMd(treeUri: Uri) {
execute {
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
val fileName = "bookmark-${dateFormat.format(Date())}.md"
val dirDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow()
fileDoc.use { outputStream ->
var name = ""
var author = ""
appDb.bookmarkDao.all.forEach {
if (it.bookName != name && it.bookAuthor != author) {
name = it.bookName
author = it.bookAuthor
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
}
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
}
}
}.onError {
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
}.onSuccess {
context.toastOnUi("导出成功")
}
}
}
@@ -0,0 +1,60 @@
package io.legato.kazusa.ui.book.bookmark
import android.content.Context
import android.view.ViewGroup
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.ItemBookmarkBinding
import io.legato.kazusa.utils.gone
import splitties.views.onClick
class BookmarkAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<Bookmark, ItemBookmarkBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding {
return ItemBookmarkBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookmarkBinding,
item: Bookmark,
payloads: MutableList<Any>
) {
binding.tvChapterName.text = item.chapterName
binding.tvBookText.gone(item.bookText.isEmpty())
binding.tvBookText.text = item.bookText
binding.tvContent.gone(item.content.isEmpty())
binding.tvContent.text = item.content
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
callback.onItemClick(it, holder.layoutPosition)
}
}
}
fun getHeaderText(position: Int): String {
return with(getItem(position)) {
"${this?.bookName ?: ""}(${this?.bookAuthor ?: ""})"
}
}
fun isItemHeader(position: Int): Boolean {
if (position == 0) return true
val lastItem = getItem(position - 1)
val curItem = getItem(position)
return !(lastItem?.bookName == curItem?.bookName
&& lastItem?.bookAuthor == curItem?.bookAuthor)
}
interface Callback {
fun onItemClick(bookmark: Bookmark, position: Int)
}
}
@@ -0,0 +1,117 @@
package io.legato.kazusa.ui.book.bookmark
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.text.TextPaint
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.spToPx
import kotlin.math.min
class BookmarkDecoration(
private val context: Context,
val adapter: BookmarkAdapter) : RecyclerView.ItemDecoration() {
private val headerLeft = 16f.dpToPx()
private val headerHeight = 32f.dpToPx()
private val headerPaint = Paint().apply {
color = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainer, Color.GRAY)
}
private val textPaint = TextPaint().apply {
textSize = 16f.spToPx()
color = MaterialColors.getColor(context, com.google.android.material.R.attr.colorPrimary, Color.BLACK)
isAntiAlias = true
}
private val textRect = Rect()
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val count = parent.childCount
for (i in 0 until count) {
val view = parent.getChildAt(i)
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
view.top.toFloat(),
headerPaint
)
val headerText = adapter.getHeaderText(position)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
(view.top - headerHeight) + headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
}
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val position = (parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
val isHeader = adapter.isItemHeader(position + 1)
val headerText = adapter.getHeaderText(position)
if (isHeader) {
val bottom = min(headerHeight.toInt(), view.bottom)
c.drawRect(
0f,
view.top - headerHeight,
parent.width.toFloat(),
bottom.toFloat(),
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2 - (headerHeight - bottom),
textPaint
)
} else {
c.drawRect(
0f,
0f,
parent.width.toFloat(),
headerHeight,
headerPaint
)
textPaint.getTextBounds(headerText, 0, headerText.length, textRect)
c.drawText(
headerText,
headerLeft,
headerHeight / 2 + textRect.height() / 2,
textPaint
)
}
c.save()
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildLayoutPosition(view)
val isHeader = adapter.isItemHeader(position)
if (isHeader) {
outRect.top = headerHeight.toInt()
}
}
}
@@ -0,0 +1,83 @@
package io.legato.kazusa.ui.book.bookmark
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Bookmark
import io.legato.kazusa.databinding.DialogBookmarkBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BookmarkDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_bookmark) {
constructor(bookmark: Bookmark, editPos: Int = -1) : this() {
arguments = Bundle().apply {
putInt("editPos", editPos)
putParcelable("bookmark", bookmark)
}
}
private val binding by viewBinding(DialogBookmarkBinding::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
}
//@Suppress("DEPRECATION")
val bookmark = arguments.getParcelable<Bookmark>("bookmark")
bookmark ?: let {
dismiss()
return
}
val editPos = arguments.getInt("editPos", -1)
binding.btnDelete.visible(editPos >= 0) // 替换 tvFooterLeft
binding.run {
tvChapterName.text = bookmark.chapterName
editBookText.setText(bookmark.bookText)
editContent.setText(bookmark.content)
btnCancel.setOnClickListener {
dismiss()
}
btnOk.setOnClickListener {
bookmark.bookText = editBookText.text?.toString() ?: ""
bookmark.content = editContent.text?.toString() ?: ""
lifecycleScope.launch {
withContext(IO) {
appDb.bookmarkDao.insert(bookmark)
}
dismiss()
}
}
btnDelete.setOnClickListener {
lifecycleScope.launch {
withContext(IO) {
appDb.bookmarkDao.delete(bookmark)
}
dismiss()
}
}
}
}
}
@@ -0,0 +1,568 @@
package io.legato.kazusa.ui.book.cache
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.textfield.TextInputLayout
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppConst.charsets
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.data.AppDatabase
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.ActivityCacheBookBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.databinding.DialogSelectSectionExportBinding
import io.legato.kazusa.help.book.getExportFileName
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.tryParesExportFileName
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.service.ExportBookService
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.ACache
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.applyNavigationBarPadding
import io.legato.kazusa.utils.applyOpenTint
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.checkWrite
import io.legato.kazusa.utils.cnCompare
import io.legato.kazusa.utils.enableCustomExport
import io.legato.kazusa.utils.flowWithLifecycleAndDatabaseChange
import io.legato.kazusa.utils.iconItemOnLongClick
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startService
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.verificationField
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import kotlin.math.max
/**
* cache/download 缓存界面
*/
class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>(),
PopupMenu.OnMenuItemClickListener,
CacheAdapter.CallBack {
override val binding by viewBinding(ActivityCacheBookBinding::inflate)
override val viewModel by viewModels<CacheViewModel>()
private val exportBookPathKey = "exportBookPath"
private val exportTypes = arrayListOf("txt", "epub")
private val layoutManager by lazy { LinearLayoutManager(this) }
private val adapter by lazy { CacheAdapter(this, this) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val groupList: ArrayList<BookGroup> = arrayListOf()
private var groupId: Long = -1
private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) {
return@registerForActivityResult
}
if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(dirPath, result.requestCode)
} else {
startExport(dirPath, result.requestCode)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
binding.titleBar.subtitle = withContext(IO) {
appDb.bookGroupDao.getByID(groupId)?.groupName
?: getString(R.string.no_group)
}
}
initRecyclerView()
initGroupData()
initBookData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_cache, menu)
menu.iconItemOnLongClick(R.id.menu_download) {
PopupMenu(this, it).apply {
inflate(R.menu.book_cache_download)
this.menu.applyOpenTint(this@CacheActivity)
setOnMenuItemClickListener(this@CacheActivity)
}.show()
}
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_replace)?.isChecked = AppConfig.exportUseReplace
// 菜单打开时读取状态[enableCustomExport]
menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport
menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName
menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav
menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile
menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook
menu.findItem(R.id.menu_export_type)?.title =
"${getString(R.string.export_type)}(${getTypeName()})"
menu.findItem(R.id.menu_export_charset)?.title =
"${getString(R.string.export_charset)}(${AppConfig.exportCharset})"
return super.onMenuOpened(featureId, menu)
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
/**
* 菜单按下回调
*/
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_download,
R.id.menu_download_after -> {
if (!CacheBook.isRun) {
adapter.getItems().forEach { book ->
CacheBook.start(
this@CacheActivity,
book,
book.durChapterIndex,
book.lastChapterIndex
)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_download_all -> {
if (!CacheBook.isRun) {
adapter.getItems().forEach { book ->
CacheBook.start(
this@CacheActivity,
book,
0,
book.lastChapterIndex
)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_export_all -> exportAll()
R.id.menu_enable_replace -> AppConfig.exportUseReplace = !item.isChecked
// 更改菜单状态[enableCustomExport]
R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked
R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked
R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked
R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked
R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked
R.id.menu_export_folder -> {
selectExportFolder(-1)
}
R.id.menu_export_file_name -> alertExportFileName()
R.id.menu_export_type -> showExportTypeConfig()
R.id.menu_export_charset -> showCharsetConfig()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
else -> if (item.groupId == R.id.menu_group) {
binding.titleBar.subtitle = item.title
groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
initBookData()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return onCompatOptionsItemSelected(item)
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
}
private fun initBookData() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
appDb.bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter {
!it.isAudio
}
when (AppConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChange(
lifecycle, table = AppDatabase.BOOK_TABLE_NAME
).catch {
AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect { books ->
adapter.setItems(books)
viewModel.loadCacheFiles(books)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun notifyItemChanged(bookUrl: String) {
kotlin.runCatching {
adapter.getItems().forEachIndexed { index, book ->
if (bookUrl == book.bookUrl) {
adapter.notifyItemChanged(index, true)
return
}
}
}
}
override fun observeLiveBus() {
viewModel.upAdapterLiveData.observe(this) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.EXPORT_BOOK) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD) {
if (!CacheBook.isRun) {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_play_24dp)
item.setTitle(R.string.download_start)
}
menu?.applyTint(this)
} else {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
menu?.applyTint(this)
}
notifyItemChanged(it)
}
observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
viewModel.cacheChapters[book.bookUrl]?.add(chapter.url)
notifyItemChanged(book.bookUrl)
}
}
override fun export(position: Int) {
val path = ACache.get().getAsString(exportBookPathKey)
lifecycleScope.launch {
if (path.isNullOrEmpty() ||
withContext(IO) { !FileDoc.fromDir(path).checkWrite() }
) {
selectExportFolder(position)
} else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(path, position)
} else {
startExport(path, position)
}
}
}
private fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(-10)
} else {
startExport(path, -10)
}
}
/**
* 配置自定义导出对话框
*
* @param path 导出路径
* @param position book位置
* @author Discut
* @since 1.0.0
*/
private fun configExportSection(path: String, position: Int) {
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
return tryParesExportFileName(js) && etEpubFilename.text.toString()
.isNotEmpty()
}
fun enableLyEtEpubFilenameIcon() {
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM
lyEtEpubFilename.setEndIconOnClickListener {
adapter.getItem(position)?.run {
lyEtEpubFilename.helperText =
if (verifyExportFileNameJsStr(etEpubFilename.text.toString()))
"${resources.getString(R.string.result_analyzed)}: ${
getExportFileName(
"epub",
1,
etEpubFilename.text.toString()
)
}"
else "Error"
} ?: run {
lyEtEpubFilename.helperText = "Error"
AppLog.put("未找到书籍,position is $position")
}
}
}
etEpubSize.setText("1")
// lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
etEpubFilename.text?.append(AppConfig.episodeExportFileName)
// 存储解析文件名的jsStr
etEpubFilename.let {
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus)
return@setOnFocusChangeListener
it.text?.run {
if (verifyExportFileNameJsStr(toString())) {
AppConfig.episodeExportFileName = toString()
}
}
}
}
tvAllExport.setOnClickListener {
cbAllExport.callOnClick()
}
tvSelectExport.setOnClickListener {
cbSelectExport.callOnClick()
}
cbSelectExport.onCheckedChangeListener = { _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = true
etInputScope.isEnabled = true
etEpubFilename.isEnabled = true
enableLyEtEpubFilenameIcon()
cbAllExport.isChecked = false
}
}
cbAllExport.onCheckedChangeListener = { _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = false
etInputScope.isEnabled = false
etEpubFilename.isEnabled = false
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
cbSelectExport.isChecked = false
}
}
etInputScope.onFocusChangeListener =
View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
etInputScope.hint = "1-5,8,10-18"
} else {
etInputScope.hint = ""
}
}
// 默认选择自定义导出
cbSelectExport.callOnClick()
}
val alertDialog = alert(titleResource = R.string.select_section_export) {
customView { alertBinding.root }
positiveButton(R.string.ok)
cancelButton()
}
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
alertBinding.apply {
if (cbAllExport.isChecked) {
startExport(path, position)
alertDialog.hide()
return@apply
}
val epubScope = etInputScope.text.toString()
if (!verificationField(epubScope)) {
etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围"
return@apply
}
etInputScope.error = null
val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1
adapter.getItem(position)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", path)
putExtra("epubSize", epubSize)
putExtra("epubScope", epubScope)
}
}
alertDialog.hide()
}
}
}
private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) {
default.add(SelectItem(path, -1))
}
exportDir.launch {
otherActions = default
requestCode = exportPosition
}
}
private fun startExport(path: String, exportPosition: Int) {
val exportType = when (AppConfig.exportType) {
1 -> "epub"
else -> "txt"
}
if (exportPosition == -10) {
if (adapter.getItems().isNotEmpty()) {
adapter.getItems().forEach { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
} else {
toastOnUi(R.string.no_book)
}
} else if (exportPosition >= 0) {
adapter.getItem(exportPosition)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
}
}
@SuppressLint("SetTextI18n")
private fun alertExportFileName() {
alert(R.string.export_file_name) {
val message = "Variable: name, author."
setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "file name js"
editView.setText(AppConfig.bookExportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookExportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
private fun getTypeName(): String {
return exportTypes.getOrElse(AppConfig.exportType) {
exportTypes[0]
}
}
private fun showExportTypeConfig() {
selector(R.string.export_type, exportTypes) { _, i ->
AppConfig.exportType = i
}
}
private fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset name"
editView.setFilterValues(charsets)
editView.setText(AppConfig.exportCharset)
}
customView { alertBinding.root }
okButton {
AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8"
}
cancelButton()
}
}
override val cacheChapters: HashMap<String, HashSet<String>>
get() = viewModel.cacheChapters
override fun exportProgress(bookUrl: String): Int? {
return ExportBookService.exportProgress[bookUrl]
}
override fun exportMsg(bookUrl: String): String? {
return ExportBookService.exportMsg[bookUrl]
}
}
@@ -0,0 +1,142 @@
package io.legato.kazusa.ui.book.cache
import android.content.Context
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import com.google.android.material.button.MaterialButton
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ItemDownloadBinding
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class CacheAdapter(context: Context, private val callBack: CallBack) :
DiffRecyclerAdapter<Book, ItemDownloadBinding>(context) {
override val diffItemCallback: DiffUtil.ItemCallback<Book>
get() = object : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.name == newItem.name
&& oldItem.author == newItem.author
}
}
override fun getViewBinding(parent: ViewGroup): ItemDownloadBinding {
return ItemDownloadBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemDownloadBinding,
item: Book,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
tvName.text = item.name
tvAuthor.text = context.getString(R.string.author_show, item.getRealAuthor())
if (item.isLocal) {
tvDownload.setText(R.string.local_book)
} else {
val cs = callBack.cacheChapters[item.bookUrl]
if (cs == null) {
tvDownload.setText(R.string.loading)
} else {
tvDownload.text =
context.getString(
R.string.download_count,
cs.size,
item.totalChapterNum
)
}
}
} else {
if (item.isLocal) {
tvDownload.setText(R.string.local_book)
} else {
val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0
tvDownload.text =
context.getString(R.string.download_count, cacheSize, item.totalChapterNum)
}
}
upDownloadIv(ivDownload, item)
upExportInfo(tvMsg, progressExport, item)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemDownloadBinding) {
binding.run {
ivDownload.setOnClickListener {
getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
} else {
CacheBook.start(context, book, 0, book.lastChapterIndex)
}
} ?: let {
CacheBook.start(context, book, 0, book.lastChapterIndex)
}
}
}
tvExport.setOnClickListener {
callBack.export(holder.layoutPosition)
}
}
}
private fun upDownloadIv(button: MaterialButton, book: Book) {
if (book.isLocal) {
button.gone()
} else {
button.visible()
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp)
} else {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play_24dp)
}
} ?: run {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play_24dp)
}
}
}
private fun upExportInfo(msgView: TextView, progressView: ProgressBar, book: Book) {
val msg = callBack.exportMsg(book.bookUrl)
if (msg != null) {
msgView.text = msg
msgView.visible()
progressView.gone()
return
}
msgView.gone()
val progress = callBack.exportProgress(book.bookUrl)
if (progress != null) {
progressView.max = book.totalChapterNum
progressView.progress = progress
progressView.visible()
return
}
progressView.gone()
}
interface CallBack {
val cacheChapters: HashMap<String, HashSet<String>>
fun export(position: Int)
fun exportProgress(bookUrl: String): Int?
fun exportMsg(bookUrl: String): String?
}
}
@@ -0,0 +1,46 @@
package io.legato.kazusa.ui.book.cache
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.utils.sendValue
import kotlinx.coroutines.ensureActive
import kotlin.collections.set
class CacheViewModel(application: Application) : BaseViewModel(application) {
val upAdapterLiveData = MutableLiveData<String>()
private var loadChapterCoroutine: Coroutine<Unit>? = null
val cacheChapters = hashMapOf<String, HashSet<String>>()
fun loadCacheFiles(books: List<Book>) {
loadChapterCoroutine?.cancel()
loadChapterCoroutine = execute {
books.forEach { book ->
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
val chapterCaches = hashSetOf<String>()
val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isNotEmpty()) {
appDb.bookChapterDao.getChapterList(book.bookUrl).also {
book.totalChapterNum = it.size
}.forEach { chapter ->
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
chapterCaches.add(chapter.url)
}
}
}
cacheChapters[book.bookUrl] = chapterCaches
upAdapterLiveData.sendValue(book.bookUrl)
}
ensureActive()
}
}
}
}
@@ -0,0 +1,117 @@
package io.legato.kazusa.ui.book.changecover
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.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.databinding.DialogChangeCoverBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
/**
* 换封面
*/
class ChangeCoverDialog() : BaseDialogFragment(R.layout.dialog_change_cover),
Toolbar.OnMenuItemClickListener,
CoverAdapter.CallBack {
constructor(name: String, author: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
}
}
private val binding by viewBinding(DialogChangeCoverBinding::bind)
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeCoverViewModel by viewModels()
private val adapter by lazy { CoverAdapter(requireContext(), this) }
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.change_cover_source)
viewModel.initData(arguments)
initMenu()
initView()
initData()
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_cover)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
}
private fun initView() {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3)
binding.recyclerView.adapter = adapter
}
private fun initData() {
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.dataFlow.conflate().collect {
adapter.setItems(it)
delay(1000)
}
}
}
}
override fun observeLiveBus() {
super.observeLiveBus()
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)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_start_stop -> viewModel.startOrStopSearch()
}
return false
}
override fun changeTo(coverUrl: String) {
callBack?.coverChangeTo(coverUrl)
dismissAllowingStateLoss()
}
interface CallBack {
fun coverChangeTo(coverUrl: String)
}
}
@@ -0,0 +1,170 @@
package io.legato.kazusa.ui.book.changecover
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.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.mapParallelSafe
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var searchSuccess: ((SearchBook) -> Unit)? = null
private var upAdapter: (() -> Unit)? = null
private var bookSourceParts = arrayListOf<BookSourcePart>()
private val defaultCover by lazy {
listOf(
SearchBook(
originName = "默认封面",
name = name,
author = author,
coverUrl = "use_default_cover"
)
)
}
private var task: Job? = null
val searchStateData = MutableLiveData<Boolean>()
var name: String = ""
var author: String = ""
val searchBooks: MutableList<SearchBook> = Collections.synchronizedList(arrayListOf())
val dataFlow = callbackFlow {
searchSuccess = { searchBook ->
if (!searchBooks.contains(searchBook)) {
searchBooks.add(searchBook)
trySend(defaultCover + searchBooks.sortedBy { it.originOrder })
}
}
upAdapter = {
trySend(defaultCover + searchBooks.sortedBy { it.originOrder })
}
appDb.searchBookDao.getEnableHasCover(name, author).let {
searchBooks.addAll(it)
trySend(defaultCover + searchBooks.toList())
}
if (searchBooks.size <= 1) {
startSearch()
}
awaitClose {
searchBooks.clear()
searchSuccess = null
upAdapter = null
}
}.flowOn(IO)
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()
}
private fun startSearch() {
execute {
stopSearch()
searchBooks.clear()
upAdapter?.invoke()
bookSourceParts.clear()
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
initSearchPool()
search()
}
}
private fun search() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
}
}.onStart {
searchStateData.postValue(true)
}.mapParallelSafe(threadCount) {
withTimeout(60000L) {
search(it)
}
}.onCompletion {
searchStateData.postValue(false)
}.catch {
AppLog.put("封面换源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun search(source: BookSource) {
if (source.getSearchRule().coverUrl.isNullOrBlank()) {
return
}
val searchBook = WebBook.searchBookAwait(
source, name,
shouldBreak = { it > 0 }).firstOrNull() ?: return
if (searchBook.name == name && searchBook.author == author
&& !searchBook.coverUrl.isNullOrEmpty()
) {
appDb.searchBookDao.insert(searchBook)
searchSuccess?.invoke(searchBook)
}
}
fun startOrStopSearch() {
if (task == null || !task!!.isActive) {
startSearch()
} else {
stopSearch()
}
}
private fun stopSearch() {
task?.cancel()
searchPool?.close()
searchStateData.postValue(false)
}
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
}
@@ -0,0 +1,55 @@
package io.legato.kazusa.ui.book.changecover
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemCoverBinding
class CoverAdapter(context: Context, val callBack: CallBack) :
DiffRecyclerAdapter<SearchBook, ItemCoverBinding>(context) {
override val diffItemCallback: DiffUtil.ItemCallback<SearchBook>
get() = 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.coverUrl == newItem.coverUrl
}
}
override fun getViewBinding(parent: ViewGroup): ItemCoverBinding {
return ItemCoverBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemCoverBinding,
item: SearchBook,
payloads: MutableList<Any>
) = binding.run {
ivCover.load(item.coverUrl, item.name, item.author, false, item.origin)
tvSource.text = item.originName
}
override fun registerListener(holder: ItemViewHolder, binding: ItemCoverBinding) {
holder.itemView.apply {
setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.changeTo(it.coverUrl ?: "")
}
}
}
}
interface CallBack {
fun changeTo(coverUrl: String)
}
}
@@ -0,0 +1,236 @@
package io.legato.kazusa.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.core.graphics.drawable.DrawableCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onLongClick
class ChangeBookSourceAdapter(
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()
&& oldItem.chapterWordCountText == newItem.chapterWordCountText
&& oldItem.respondTime == newItem.respondTime
}
}
override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding {
return ItemChangeSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChangeSourceBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
binding.apply {
if (payloads.isEmpty()) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
tvCurrentChapterWordCount.text = item.chapterWordCountText
tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime)
if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
val score = callBack.getBookScore(item)
if (score > 0) {
binding.ivBad.gone()
binding.ivGood.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_A200)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
} else if (score < 0) {
binding.ivGood.gone()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
} else {
binding.ivGood.visible()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
binding.ivGood.setOnClickListener {
if (binding.ivBad.isVisible) {
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_A200)
)
binding.ivBad.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 1)
}
} else {
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
binding.ivBad.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
binding.ivBad.setOnClickListener {
if (binding.ivGood.isVisible) {
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
binding.ivGood.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, -1)
}
} else {
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
binding.ivGood.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.bookUrl != callBack.oldBookUrl) {
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 -> context.alert(R.string.draw) {
setMessage(context.getString(R.string.sure_del) + "\n" + searchBook.originName)
noButton()
yesButton {
callBack.deleteSource(searchBook)
updateItems(0, itemCount, listOf<Int>())
}
}
}
true
}
popupMenu.show()
}
interface CallBack {
val oldBookUrl: 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)
fun setBookScore(searchBook: SearchBook, score: Int)
fun getBookScore(searchBook: SearchBook): Int
}
}
@@ -0,0 +1,460 @@
package io.legato.kazusa.ui.book.changesource
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.DialogBookChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.source.manage.BookSourceActivity
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.getCompatDrawable
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.transaction
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
/**
* 换源界面
*/
class ChangeBookSourceDialog() : BaseDialogFragment(R.layout.dialog_book_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(DialogBookChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeBookSourceViewModel by viewModels()
private val waitDialog by lazy { WaitDialog(requireContext()) }
private val adapter by lazy { ChangeBookSourceAdapter(requireContext(), viewModel, this) }
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
val origin = it.data?.getStringExtra("origin") ?: return@registerForActivityResult
viewModel.startSearch(origin)
}
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
lifecycleScope.launch {
context?.alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
cancelButton()
okButton {
AppConfig.searchGroup = ""
upGroupMenuName()
viewModel.startSearch()
}
}
}
}
}
}
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
showTitle()
initMenu()
initRecyclerView()
initNavigationView()
initSearchView()
initBottomBar()
initLiveData()
viewModel.searchFinishCallback = searchFinishCallback
}
override fun onDestroy() {
super.onDestroy()
viewModel.searchFinishCallback = null
}
private fun showTitle() {
binding.toolBar.title = viewModel.name
binding.toolBar.subtitle = viewModel.author
binding.toolBar.navigationIcon =
getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
//binding.toolBar.elevation = requireContext().elevation
}
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
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
}
private fun initRecyclerView() {
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 = ""
binding.toolBar.navigationIcon = null
}
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 initNavigationView() {
binding.toolBar.navigationIcon =
getCompatDrawable(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
binding.toolBar.setNavigationContentDescription(
androidx.appcompat.R.string.abc_action_bar_up_description
)
binding.toolBar.setNavigationOnClickListener {
dismissAllowingStateLoss()
}
kotlin.runCatching {
val mNavButtonViewField = Toolbar::class.java.getDeclaredField("mNavButtonView")
mNavButtonViewField.isAccessible = true
val navigationView = mNavButtonViewField.get(binding.toolBar) as ImageButton
// val isLight = ColorUtils.isColorLight(primaryColor)
// val textColor = requireContext().getPrimaryTextColor(isLight)
//navigationView.setColorFilter(textColor)
}
}
private fun initBottomBar() {
binding.tvDur.text = callBack?.oldBook?.originName
binding.tvDur.setOnClickListener {
scrollToDurSource()
}
binding.ivTop.setOnClickListener {
binding.recyclerView.scrollToPosition(0)
}
binding.ivBottom.setOnClickListener {
binding.recyclerView.scrollToPosition(adapter.itemCount - 1)
}
}
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)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.searchDataFlow.conflate().collect {
adapter.setItems(it)
delay(1000)
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.changeSourceProgress
.drop(1)
.collect { (count, name) ->
binding.tvDur.text =
getString(
R.string.change_source_progress,
adapter.itemCount,
count,
viewModel.totalSourceCount,
name
)
delay(500)
}
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().conflate().collect {
groups.clear()
groups.addAll(it)
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_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
R.id.menu_close -> dismissAllowingStateLoss()
R.id.menu_refresh_list -> viewModel.startRefreshList()
else -> if (item?.groupId == R.id.source_group && !item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
AppConfig.searchGroup = ""
} else {
AppConfig.searchGroup = item.title.toString()
}
upGroupMenuName()
lifecycleScope.launch(IO) {
viewModel.stopSearch()
if (viewModel.refresh()) {
viewModel.startSearch()
}
}
}
}
return false
}
private fun scrollToDurSource() {
adapter.getItems().forEachIndexed { index, searchBook ->
if (searchBook.bookUrl == oldBookUrl) {
(binding.recyclerView.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(index, 60.dpToPx())
return
}
}
}
override fun changeTo(searchBook: SearchBook) {
val oldBookType = callBack?.oldBook?.type ?: 0
if (searchBook.sameBookTypeLocal(oldBookType)) {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
} else {
alert(
titleResource = R.string.book_type_different,
messageResource = R.string.soure_change_source
) {
okButton {
changeSource(searchBook) {
dismissAllowingStateLoss()
}
}
cancelButton()
}
}
}
override val oldBookUrl: 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 (oldBookUrl == searchBook.bookUrl) {
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
override fun setBookScore(searchBook: SearchBook, score: Int) {
viewModel.setBookScore(searchBook, score)
}
override fun getBookScore(searchBook: SearchBook): Int {
return viewModel.getBookScore(searchBook)
}
private fun changeSource(searchBook: SearchBook, onSuccess: (() -> Unit)? = null) {
waitDialog.setText(R.string.load_toc)
waitDialog.show()
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
val coroutine = viewModel.getToc(book, { toc, source ->
waitDialog.dismiss()
callBack?.changeTo(source, book, toc)
onSuccess?.invoke()
}, {
waitDialog.dismiss()
AppLog.put("换源获取目录出错\n$it", it, true)
})
waitDialog.setOnCancelListener {
coroutine.cancel()
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
binding.toolBar.menu.findItem(R.id.menu_group)?.run {
subMenu?.transaction { menu ->
val selectedGroup = AppConfig.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.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) {
title = getString(R.string.group) + "(" + AppConfig.searchGroup + ")"
} else {
allItem.isChecked = true
title = getString(R.string.group)
}
}
}
}
/**
* 更新分组菜单名
*/
private fun upGroupMenuName() {
val menuGroup = binding.toolBar.menu.findItem(R.id.menu_group)
val searchGroup = AppConfig.searchGroup
if (searchGroup.isEmpty()) {
menuGroup?.title = getString(R.string.group)
} else {
menuGroup?.title = getString(R.string.group) + "($searchGroup)"
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.SOURCE_CHANGED) {
adapter.notifyItemRangeChanged(
0,
adapter.itemCount,
bundleOf(Pair("upCurSource", oldBookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
}
}
@@ -0,0 +1,580 @@
package io.legato.kazusa.ui.book.changesource
import android.app.Application
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.book.primaryStr
import io.legato.kazusa.help.book.releaseHtmlData
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.SourceConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.source.SourceHelp
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.internString
import io.legato.kazusa.utils.mapParallel
import io.legato.kazusa.utils.mapParallelSafe
import io.legato.kazusa.utils.onEachIndexed
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData<Boolean>()
var searchFinishCallback: ((isEmpty: Boolean) -> Unit)? = null
var name: String = ""
var author: String = ""
private var fromReadBookActivity = false
private var oldBook: Book? = null
private var screenKey: String = ""
private var bookSourceParts = arrayListOf<BookSourcePart>()
val totalSourceCount: Int
get() = bookSourceParts.size
private var searchBookList = arrayListOf<SearchBook>()
private val searchBooks = Collections.synchronizedList(arrayListOf<SearchBook>())
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
private val _changeSourceProgress = MutableStateFlow(0 to "")
val changeSourceProgress = _changeSourceProgress.asStateFlow()
private var tocMapChapterCount = 0
private val contentProcessor by lazy {
ContentProcessor.get(oldBook!!)
}
private var searchCallback: SourceCallback? = null
private val chapterNumRegex = "^\\[(\\d+)]".toRegex()
private val comparatorBase by lazy {
compareByDescending<SearchBook> { getBookScore(it) }
.thenByDescending { SourceConfig.getSourceScore(it.origin) }
}
private val defaultComparator by lazy {
comparatorBase.thenBy { it.originOrder }
}
private val wordCountComparator by lazy {
comparatorBase.thenByDescending { it.chapterWordCount > 1000 }
.thenByDescending { getChapterNum(it.chapterWordCountText) }
.thenByDescending { it.chapterWordCount }
.thenBy { it.originOrder }
}
private var task: Job? = null
val bookMap = ConcurrentHashMap<String, Book>()
val searchDataFlow = callbackFlow {
searchCallback = object : SourceCallback {
override fun searchSuccess(searchBook: SearchBook) {
searchBook.releaseHtmlData()
appDb.searchBookDao.insert(searchBook)
when {
screenKey.isEmpty() -> searchBooks.add(searchBook)
searchBook.name.contains(screenKey) -> searchBooks.add(searchBook)
else -> return
}
trySend(arrayOf(searchBooks))
}
override fun upAdapter() {
trySend(arrayOf(searchBooks))
}
}
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
trySend(arrayOf(searchBooks))
}
if (searchBooks.isEmpty()) {
startSearch()
}
awaitClose {
searchCallback = null
}
}.map {
kotlin.runCatching {
val comparator = if (AppConfig.changeSourceLoadWordCount) {
wordCountComparator
} else {
defaultComparator
}
searchBooks.sortedWith(comparator)
}.onFailure {
AppLog.put("换源排序出错\n${it.localizedMessage}", it)
}.getOrDefault(searchBooks)
}.flowOn(IO)
override fun onCleared() {
super.onCleared()
searchPool?.close()
}
@CallSuper
open fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) {
arguments?.let { bundle ->
bundle.getString("name")?.let {
name = it
}
bundle.getString("author")?.let {
author = it.replace(AppPattern.authorRegex, "")
}
this.fromReadBookActivity = fromReadBookActivity
oldBook = book
}
}
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
}
fun refresh(): Boolean {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
return searchBooks.isEmpty()
}
/**
* 搜索书籍
*/
fun startSearch() {
execute {
stopSearch()
if (searchBooks.isNotEmpty()) {
appDb.searchBookDao.delete(*searchBooks.toTypedArray())
searchBooks.clear()
}
searchCallback?.upAdapter()
bookSourceParts.clear()
tocMap.clear()
bookMap.clear()
tocMapChapterCount = 0
_changeSourceProgress.value = 0 to ""
val searchGroup = AppConfig.searchGroup
if (searchGroup.isBlank()) {
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup)
if (sources.isEmpty()) {
AppConfig.searchGroup = ""
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
bookSourceParts.addAll(sources)
}
}
initSearchPool()
search()
}
}
fun startSearch(origin: String) {
execute {
stopSearch()
bookSourceParts.clear()
tocMap.clear()
bookMap.clear()
tocMapChapterCount = 0
bookSourceParts.add(appDb.bookSourceDao.getBookSourcePart(origin)!!)
searchBooks.removeIf { it.origin == origin }
initSearchPool()
search()
}
}
private fun search() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
}
}.onStart {
searchStateData.postValue(true)
}.mapParallel(threadCount) {
try {
withTimeout(60000L) {
search(it)
}
} catch (_: Throwable) {
currentCoroutineContext().ensureActive()
}
it
}.onEachIndexed { index, value ->
_changeSourceProgress.update { _ ->
index + 1 to value.bookSourceName
}
}.onCompletion {
ensureActive()
searchStateData.postValue(false)
searchFinishCallback?.invoke(searchBooks.isEmpty())
}.catch {
AppLog.put("换源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun search(source: BookSource) {
val checkAuthor = AppConfig.changeSourceCheckAuthor
val loadInfo = AppConfig.changeSourceLoadInfo
val loadToc = AppConfig.changeSourceLoadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
filter = { fName, fAuthor ->
fName == name && (!checkAuthor || fAuthor.contains(author))
})
resultBooks.forEach { searchBook ->
when {
loadInfo || loadToc || loadWordCount -> {
loadBookInfo(source, searchBook.toBook())
}
else -> {
searchCallback?.searchSuccess(searchBook)
}
}
}
}
private suspend fun loadBookInfo(source: BookSource, book: Book) {
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) {
loadBookToc(source, book)
} else {
//从详情页里获取最新章节
val searchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}
}
private suspend fun loadBookToc(source: BookSource, book: Book) {
val chapters = WebBook.getChapterListAwait(source, book).getOrThrow()
for (chapter in chapters) {
chapter.internString()
}
if (tocMapChapterCount < 30000) {
tocMapChapterCount += chapters.size
tocMap[book.primaryStr()] = chapters
}
bookMap[book.primaryStr()] = book
book.releaseHtmlData()
if (AppConfig.changeSourceLoadWordCount) {
loadBookWordCount(source, book, chapters)
} else {
val searchBook = book.toSearchBook()
searchCallback?.searchSuccess(searchBook)
}
}
private suspend fun loadBookWordCount(
source: BookSource,
book: Book,
chapters: List<BookChapter>
) = coroutineScope {
val chapterIndex = if (fromReadBookActivity) {
BookHelp.getDurChapter(oldBook!!, chapters)
} else {
chapters.lastIndex
}
val bookChapter = chapters[chapterIndex]
var title = bookChapter.title.trim()
if (title.length > 20) {
title = title.substring(0, 20) + "…"
}
val startTime = System.currentTimeMillis()
val pair = try {
val nextChapterUrl = chapters.getOrNull(chapterIndex + 1)?.url
var content = WebBook.getContentAwait(source, book, bookChapter, nextChapterUrl, false)
content = contentProcessor.getContent(oldBook!!, bookChapter, content, false).toString()
val len = content.length
len to "[${chapterIndex + 1}] ${title}\n字数:${len}"
} catch (t: Throwable) {
if (t is CancellationException) throw t
-1 to "[${chapterIndex + 1}] ${title}\n获取字数失败:${t.localizedMessage}"
}
val endTime = System.currentTimeMillis()
val searchBook = book.toSearchBook().apply {
chapterWordCountText = pair.second
chapterWordCount = pair.first
respondTime = (endTime - startTime).toInt()
}
searchCallback?.searchSuccess(searchBook)
}
fun onLoadWordCountChecked(isChecked: Boolean) {
if (isChecked) {
startRefreshList(true)
}
}
/**
* 刷新列表
*/
fun startRefreshList(onlyRefreshNoWordCountBook: Boolean = false) {
execute {
stopSearch()
searchBookList.clear()
if (onlyRefreshNoWordCountBook) {
searchBooks.filterTo(searchBookList) {
it.chapterWordCountText == null
}
searchBooks.removeIf { it.chapterWordCountText == null }
} else {
searchBookList.addAll(searchBooks)
searchBooks.clear()
}
searchCallback?.upAdapter()
initSearchPool()
refreshList()
}
}
private fun refreshList() {
task = viewModelScope.launch(searchPool!!) {
flow {
for (searchBook in searchBookList) {
emit(searchBook)
}
}.onStart {
searchStateData.postValue(true)
}.mapParallelSafe(threadCount) {
val source = appDb.bookSourceDao.getBookSource(it.origin)!!
withTimeout(60000L) {
loadBookInfo(source, it.toBook())
}
}.onCompletion {
searchStateData.postValue(false)
}.catch {
AppLog.put("换源刷新列表出错\n${it.localizedMessage}", it)
}.collect()
}
}
private fun getDbSearchBooks(): List<SearchBook> {
return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.changeSourceByGroup(
name, author, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.changeSourceByGroup(
name, "", AppConfig.searchGroup
)
}
} else {
if (AppConfig.changeSourceCheckAuthor) {
appDb.searchBookDao.changeSourceSearch(
name, author, screenKey, AppConfig.searchGroup
)
} else {
appDb.searchBookDao.changeSourceSearch(
name, "", screenKey, AppConfig.searchGroup
)
}
}
}
/**
* 筛选
*/
fun screen(key: String?) {
screenKey = key?.trim() ?: ""
execute {
getDbSearchBooks().let {
searchBooks.clear()
searchBooks.addAll(it)
searchCallback?.upAdapter()
}
}
}
fun startOrStopSearch() {
if (task == null || !task!!.isActive) {
startSearch()
} else {
stopSearch()
}
}
fun stopSearch() {
task?.cancel()
searchPool?.close()
searchStateData.postValue(false)
}
fun getToc(
book: Book,
onSuccess: (toc: List<BookChapter>, source: BookSource) -> Unit,
onError: (e: Throwable) -> Unit
): Coroutine<Pair<List<BookChapter>, BookSource>> {
return execute {
val toc = tocMap[book.primaryStr()]
if (toc != null) {
val source = appDb.bookSourceDao.getBookSource(book.origin)
return@execute Pair(toc, source!!)
}
val result = getToc(book).getOrThrow()
tocMap[book.primaryStr()] = result.first
return@execute result
}.onSuccess {
onSuccess.invoke(it.first, it.second)
}.onError {
onError.invoke(it)
}
}
suspend fun getToc(book: Book): Result<Pair<List<BookChapter>, BookSource>> {
return kotlin.runCatching {
val source = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
Pair(toc, source)
}
}
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 {
SourceHelp.deleteBookSource(searchBook.origin)
appDb.searchBookDao.delete(searchBook)
}
searchBooks.remove(searchBook)
searchCallback?.upAdapter()
}
fun autoChangeSource(
bookType: Int?,
onSuccess: (book: Book, toc: List<BookChapter>, source: BookSource) -> Unit
) {
execute {
searchBooks.forEach {
if (it.type == bookType) {
val book = it.toBook()
val result = getToc(book).getOrNull()
if (result != null) {
return@execute Triple(book, result.first, result.second)
}
}
}
throw NoStackTraceException("没有有效源")
}.onSuccess {
onSuccess.invoke(it.first, it.second, it.third)
}.onError {
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}
}
fun setBookScore(searchBook: SearchBook, score: Int) {
execute {
SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score)
searchCallback?.upAdapter()
}
}
fun getBookScore(searchBook: SearchBook): Int {
return SourceConfig.getBookScore(searchBook.origin, searchBook.name, searchBook.author)
}
private fun getChapterNum(wordCountText: String?): Int {
wordCountText ?: return -1
return chapterNumRegex.find(wordCountText)?.groupValues?.get(1)?.toIntOrNull() ?: -1
}
interface SourceCallback {
fun searchSuccess(searchBook: SearchBook)
fun upAdapter()
}
}
@@ -0,0 +1,205 @@
package io.legato.kazusa.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.core.graphics.drawable.DrawableCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import io.legato.kazusa.R
import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemChangeSourceBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onLongClick
class ChangeChapterSourceAdapter(
context: Context,
val viewModel: ChangeChapterSourceViewModel,
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>
) {
binding.apply {
if (payloads.isEmpty()) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
tvCurrentChapterWordCount.text = item.chapterWordCountText
tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime)
if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
val score = callBack.getBookScore(item)
if (score > 0) {
binding.ivBad.gone()
binding.ivGood.visible()
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200))
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100))
} else if (score < 0) {
binding.ivGood.gone()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_A200)
)
} else {
binding.ivGood.visible()
binding.ivBad.visible()
DrawableCompat.setTint(
binding.ivGood.drawable,
appCtx.getCompatColor(R.color.md_red_100)
)
DrawableCompat.setTint(
binding.ivBad.drawable,
appCtx.getCompatColor(R.color.md_blue_100)
)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
binding.ivGood.setOnClickListener {
if (binding.ivBad.isVisible) {
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_A200))
binding.ivBad.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 1)
}
} else {
DrawableCompat.setTint(binding.ivGood.drawable, appCtx.getCompatColor(R.color.md_red_100))
binding.ivBad.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
binding.ivBad.setOnClickListener {
if (binding.ivGood.isVisible) {
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_A200))
binding.ivGood.gone()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, -1)
}
} else {
DrawableCompat.setTint(binding.ivBad.drawable, appCtx.getCompatColor(R.color.md_blue_100))
binding.ivGood.visible()
getItem(holder.layoutPosition)?.let {
callBack.setBookScore(it, 0)
}
}
}
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openToc(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 oldBookUrl: String?
fun openToc(searchBook: SearchBook)
fun topSource(searchBook: SearchBook)
fun bottomSource(searchBook: SearchBook)
fun editSource(searchBook: SearchBook)
fun disableSource(searchBook: SearchBook)
fun deleteSource(searchBook: SearchBook)
fun setBookScore(searchBook: SearchBook, score: Int)
fun getBookScore(searchBook: SearchBook): Int
}
}
@@ -0,0 +1,414 @@
package io.legato.kazusa.ui.book.changesource
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.DialogChapterChangeSourceBinding
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.source.manage.BookSourceActivity
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.setLayout
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 io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_change_source),
Toolbar.OnMenuItemClickListener,
ChangeChapterSourceAdapter.CallBack,
ChangeChapterTocAdapter.Callback {
constructor(name: String, author: String, chapterIndex: Int, chapterTitle: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
putInt("chapterIndex", chapterIndex)
putString("chapterTitle", chapterTitle)
}
}
private val binding by viewBinding(DialogChapterChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeChapterSourceViewModel by viewModels()
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
viewModel.startSearch()
}
private val searchBookAdapter by lazy {
ChangeChapterSourceAdapter(requireContext(), viewModel, this)
}
private val tocAdapter by lazy {
ChangeChapterTocAdapter(requireContext(), this)
}
private val contentSuccess: (content: String) -> Unit = {
binding.loadingToc.gone()
callBack?.replaceContent(it)
dismissAllowingStateLoss()
}
private var searchBook: SearchBook? = null
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchGroup = AppConfig.searchGroup
if (searchGroup.isNotEmpty()) {
lifecycleScope.launch {
context?.alert("搜索结果为空") {
setMessage("${searchGroup}分组搜索结果为空,是否切换到全部分组")
noButton()
yesButton {
AppConfig.searchGroup = ""
upGroupMenu()
viewModel.startSearch()
}
}
}
}
}
}
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
showTitle()
initMenu()
initView()
initRecyclerView()
initSearchView()
initBottomBar()
initLiveData()
viewModel.searchFinishCallback = searchFinishCallback
activity?.onBackPressedDispatcher?.addCallback(this) {
if (binding.clToc.isVisible) {
binding.clToc.gone()
return@addCallback
}
dismissAllowingStateLoss()
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.searchFinishCallback = null
}
private fun showTitle() {
binding.toolBar.title = viewModel.chapterTitle
}
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
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
}
private fun initView() {
binding.ivHideToc.setOnClickListener {
binding.clToc.gone()
}
//binding.flHideToc.elevation = requireContext().elevation
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = searchBookAdapter
searchBookAdapter.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)
}
}
})
binding.recyclerViewToc.adapter = tocAdapter
}
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 initBottomBar() {
binding.tvDur.text = callBack?.oldBook?.originName
binding.tvDur.setOnClickListener {
scrollToDurSource()
}
binding.ivTop.setOnClickListener {
binding.recyclerView.scrollToPosition(0)
}
binding.ivBottom.setOnClickListener {
binding.recyclerView.scrollToPosition(searchBookAdapter.itemCount - 1)
}
}
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)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
lifecycleScope.launch {
repeatOnLifecycle(STARTED) {
viewModel.searchDataFlow.conflate().collect {
searchBookAdapter.setItems(it)
delay(1000)
}
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().conflate().collect {
groups.clear()
groups.addAll(it)
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_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
else -> if (item?.groupId == R.id.source_group && !item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
AppConfig.searchGroup = ""
} else {
AppConfig.searchGroup = item.title.toString()
}
lifecycleScope.launch(IO) {
viewModel.stopSearch()
if (viewModel.refresh()) {
viewModel.startSearch()
}
}
}
}
return false
}
private fun scrollToDurSource() {
searchBookAdapter.getItems().forEachIndexed { index, searchBook ->
if (searchBook.bookUrl == oldBookUrl) {
(binding.recyclerView.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(index, 60.dpToPx())
return
}
}
}
override fun openToc(searchBook: SearchBook) {
this.searchBook = searchBook
tocAdapter.setItems(null)
binding.clToc.visible()
binding.loadingToc.visible()
val book = searchBook.toBook()
viewModel.getToc(book, { toc: List<BookChapter>, _: BookSource ->
tocAdapter.durChapterIndex =
BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc)
binding.loadingToc.gone()
tocAdapter.setItems(toc)
binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5)
}, {
binding.clToc.gone()
AppLog.put("单章换源获取目录出错\n$it", it, true)
})
}
override val oldBookUrl: 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 (oldBookUrl == searchBook.bookUrl) {
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
override fun setBookScore(searchBook: SearchBook, score: Int) {
viewModel.setBookScore(searchBook, score)
}
override fun getBookScore(searchBook: SearchBook): Int {
return viewModel.getBookScore(searchBook)
}
override fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) {
searchBook?.let {
binding.loadingToc.visible()
viewModel.getContent(it.toBook(), bookChapter, nextChapterUrl, contentSuccess) { msg ->
binding.loadingToc.gone()
binding.clToc.gone()
toastOnUi(msg)
}
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
binding.toolBar.menu.findItem(R.id.menu_group)?.subMenu?.transaction { menu ->
val selectedGroup = AppConfig.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.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) {
searchBookAdapter.notifyItemRangeChanged(
0,
searchBookAdapter.itemCount,
bundleOf(Pair("upCurSource", oldBookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
fun replaceContent(content: String)
}
}
@@ -0,0 +1,46 @@
package io.legato.kazusa.ui.book.changesource
import android.app.Application
import android.os.Bundle
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.model.webBook.WebBook
@Suppress("MemberVisibilityCanBePrivate")
class ChangeChapterSourceViewModel(application: Application) :
ChangeBookSourceViewModel(application) {
var chapterIndex: Int = 0
var chapterTitle: String = ""
override fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) {
super.initData(arguments, book, fromReadBookActivity)
arguments?.let { bundle ->
bundle.getString("chapterTitle")?.let {
chapterTitle = it
}
chapterIndex = bundle.getInt("chapterIndex")
}
}
fun getContent(
book: Book,
chapter: BookChapter,
nextChapterUrl: String?,
success: (content: String) -> Unit,
error: (msg: String) -> Unit
) {
execute {
val bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false)
}.onSuccess {
success.invoke(it)
}.onError {
error.invoke(it.localizedMessage ?: "获取正文出错")
}
}
}
@@ -0,0 +1,70 @@
package io.legato.kazusa.ui.book.changesource
import android.content.Context
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.data.entities.BookChapter
import io.legato.kazusa.databinding.ItemChapterListBinding
import io.legato.kazusa.lib.theme.ThemeUtils
//import io.legado.app.lib.theme.accentColor
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class ChangeChapterTocAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<BookChapter, ItemChapterListBinding>(context) {
var durChapterIndex = 0
override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding {
return ItemChapterListBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChapterListBinding,
item: BookChapter,
payloads: MutableList<Any>
) {
binding.run {
val isDur = durChapterIndex == item.index
if (isDur) {
//tvChapterName.setTextColor(context.accentColor)
} else {
tvChapterName.setTextColor(context.getCompatColor(R.color.primaryText))
}
tvChapterName.text = item.title
if (item.isVolume) {
//卷名,如第一卷 突出显示
tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press))
} else {
//普通章节 保持不变
tvChapterItem.background =
ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground)
}
if (!item.tag.isNullOrEmpty() && !item.isVolume) {
//卷名不显示tag(更新时间规则)
tvTag.text = item.tag
tvTag.visible()
} else {
tvTag.gone()
}
ivChecked.setImageResource(R.drawable.ic_check)
ivChecked.visible(isDur)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callback.clickChapter(it, getItem(holder.layoutPosition + 1)?.url)
}
}
}
interface Callback {
fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?)
}
}
@@ -0,0 +1,96 @@
package io.legato.kazusa.ui.book.explore
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ActivityExploreShowBinding
import io.legato.kazusa.databinding.ViewLoadMoreBinding
import io.legato.kazusa.ui.book.info.BookInfoActivity
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
/**
* 发现列表
*/
class ExploreShowActivity : VMBaseActivity<ActivityExploreShowBinding, ExploreShowViewModel>(),
ExploreShowAdapter.CallBack {
override val binding by viewBinding(ActivityExploreShowBinding::inflate)
override val viewModel by viewModels<ExploreShowViewModel>()
private val adapter by lazy { ExploreShowAdapter(this, this) }
private val loadMoreView by lazy { LoadMoreView(this) }
override fun onActivityCreated(savedInstanceState: Bundle?) {
binding.titleBar.title = intent.getStringExtra("exploreName")
initRecyclerView()
viewModel.booksData.observe(this) { upData(it) }
viewModel.initData(intent)
viewModel.errorLiveData.observe(this) {
loadMoreView.error(it)
}
viewModel.upAdapterLiveData.observe(this) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null))
}
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
adapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.startLoad()
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading) {
scrollToBottom(true)
}
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
scrollToBottom()
}
}
})
}
private fun scrollToBottom(forceLoad: Boolean = false) {
if ((loadMoreView.hasMore && !loadMoreView.isLoading) || forceLoad) {
loadMoreView.hasMore()
viewModel.explore()
}
}
private fun upData(books: List<SearchBook>) {
loadMoreView.stopLoad()
if (books.isEmpty() && adapter.isEmpty()) {
loadMoreView.noMore(getString(R.string.empty))
} else if (adapter.getActualItemCount() == books.size) {
loadMoreView.noMore()
} else {
adapter.setItems(books)
}
}
override fun isInBookshelf(name: String, author: String): Boolean {
return viewModel.isInBookShelf(name, author)
}
override fun showBookInfo(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
}
@@ -0,0 +1,98 @@
package io.legato.kazusa.ui.book.explore
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.isVisible
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.Book
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.databinding.ItemSearchBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.visible
class ExploreShowAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<SearchBook, ItemSearchBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSearchBinding {
return ItemSearchBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemSearchBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
bind(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bindChange(binding, item, bundle)
}
}
}
private fun bind(binding: ItemSearchBinding, item: SearchBook) {
binding.run {
tvName.text = item.name
tvAuthor.text = context.getString(R.string.author_show, item.author)
ivInBookshelf.isVisible = callBack.isInBookshelf(item.name, item.author)
if (item.latestChapterTitle.isNullOrEmpty()) {
tvLasted.gone()
} else {
tvLasted.text = context.getString(R.string.lasted_show, item.latestChapterTitle)
tvLasted.visible()
}
tvIntroduce.text = item.trimIntro(context)
val kinds = item.getKindList()
if (kinds.isEmpty()) {
llKind.gone()
} else {
llKind.visible()
llKind.setLabels(kinds)
}
ivCover.load(
item.coverUrl,
item.name,
item.author,
AppConfig.loadCoverOnlyWifi,
item.origin
)
}
}
private fun bindChange(binding: ItemSearchBinding, item: SearchBook, bundle: Bundle) {
binding.run {
bundle.keySet().forEach {
when (it) {
"isInBookshelf" -> ivInBookshelf.isVisible =
callBack.isInBookshelf(item.name, item.author)
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemSearchBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.showBookInfo(it.toBook())
}
}
}
interface CallBack {
/**
* 是否已经加入书架
*/
fun isInBookshelf(name: String, author: String): Boolean
fun showBookInfo(book: Book)
}
}
@@ -0,0 +1,93 @@
package io.legato.kazusa.ui.book.explore
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.BuildConfig
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.help.book.isNotShelf
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.stackTraceStr
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.mapLatest
import java.util.concurrent.ConcurrentHashMap
@OptIn(ExperimentalCoroutinesApi::class)
class ExploreShowViewModel(application: Application) : BaseViewModel(application) {
val bookshelf: MutableSet<String> = ConcurrentHashMap.newKeySet()
val upAdapterLiveData = MutableLiveData<String>()
val booksData = MutableLiveData<List<SearchBook>>()
val errorLiveData = MutableLiveData<String>()
private var bookSource: BookSource? = null
private var exploreUrl: String? = null
private var page = 1
private var books = linkedSetOf<SearchBook>()
init {
execute {
appDb.bookDao.flowAll().mapLatest { books ->
val keys = arrayListOf<String>()
books.filterNot { it.isNotShelf }
.forEach {
keys.add("${it.name}-${it.author}")
keys.add(it.name)
}
keys
}.catch {
AppLog.put("发现列表界面获取书籍数据失败\n${it.localizedMessage}", it)
}.collect {
bookshelf.clear()
bookshelf.addAll(it)
upAdapterLiveData.postValue("isInBookshelf")
}
}.onError {
AppLog.put("加载书架数据失败", it)
}
}
fun initData(intent: Intent) {
execute {
val sourceUrl = intent.getStringExtra("sourceUrl")
exploreUrl = intent.getStringExtra("exploreUrl")
if (bookSource == null && sourceUrl != null) {
bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
}
explore()
}
}
fun explore() {
val source = bookSource
val url = exploreUrl
if (source == null || url == null) return
WebBook.exploreBook(viewModelScope, source, url, page)
.timeout(if (BuildConfig.DEBUG) 0L else 30000L)
.onSuccess(IO) { searchBooks ->
books.addAll(searchBooks)
booksData.postValue(books.toList())
appDb.searchBookDao.insert(*searchBooks.toTypedArray())
page++
}.onError {
it.printOnDebug()
errorLiveData.postValue(it.stackTraceStr)
}
}
fun isInBookShelf(name: String, author: String): Boolean {
return if (author.isNotBlank()) {
bookshelf.contains("$name-$author")
} else {
bookshelf.contains(name)
}
}
}
@@ -0,0 +1,135 @@
package io.legato.kazusa.ui.book.group
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseBottomSheetDialogFragment
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogBookGroupEditBinding
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.SelectImageContract
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import splitties.init.appCtx
import splitties.views.onClick
import java.io.FileOutputStream
class GroupEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_book_group_edit) {
constructor(bookGroup: BookGroup? = null) : this() {
arguments = Bundle().apply {
putParcelable("group", bookGroup?.copy())
}
}
private val binding by viewBinding(DialogBookGroupEditBinding::bind)
private val viewModel by viewModels<GroupViewModel>()
private var bookGroup: BookGroup? = null
private val selectImage = registerForActivityResult(SelectImageContract()) {
it.uri ?: return@registerForActivityResult
readUri(it.uri) { fileDoc, inputStream ->
try {
var file = requireContext().externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = it.uri.inputStream(requireContext()).getOrThrow().use { tmp ->
MD5Utils.md5Encode(tmp) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
binding.ivCover.load(file.absolutePath)
} catch (e: Exception) {
appCtx.toastOnUi(e.localizedMessage)
}
}
}
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
@Suppress("DEPRECATION")
bookGroup = arguments?.getParcelable("group")
bookGroup?.let {
binding.btnDelete.visible(it.groupId > 0 || it.groupId == Long.MIN_VALUE)
binding.tieGroupName.setText(it.groupName)
binding.ivCover.load(it.cover)
binding.spSort.setSelection(it.bookSort + 1)
binding.cbEnableRefresh.isChecked = it.enableRefresh
} ?: let {
binding.toolBar.title = getString(R.string.add_group)
binding.btnDelete.gone()
binding.ivCover.load()
}
binding.run {
ivCover.onClick {
selectImage.launch()
}
btnCancel.onClick {
dismiss()
}
btnOk.onClick {
val groupName = tieGroupName.text?.toString()
if (groupName.isNullOrEmpty()) {
toastOnUi("分组名称不能为空")
} else {
val bookSort = binding.spSort.selectedItemPosition - 1
val coverPath = binding.ivCover.bitmapPath
val enableRefresh = binding.cbEnableRefresh.isChecked
bookGroup?.let {
it.groupName = groupName
it.cover = coverPath
it.bookSort = bookSort
it.enableRefresh = enableRefresh
viewModel.upGroup(it) {
dismiss()
}
} ?: let {
viewModel.addGroup(
groupName,
bookSort,
enableRefresh,
coverPath
) {
dismiss()
}
}
}
}
btnDelete.onClick {
deleteGroup {
bookGroup?.let {
viewModel.delGroup(it) {
dismiss()
}
}
}
}
}
}
private fun deleteGroup(ok: () -> Unit) {
alert(R.string.delete, R.string.sure_del) {
yesButton {
ok.invoke()
}
noButton()
}
}
}
@@ -0,0 +1,164 @@
package io.legato.kazusa.ui.book.group
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.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemBookGroupManageBinding
//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.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 书籍分组管理
*/
class GroupManageDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel: GroupViewModel by viewModels()
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.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.setTitle(R.string.group_manage)
initView()
initData()
initMenu()
}
private fun initView() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
//binding.tvOk.setTextColor(requireContext().accentColor)
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("书籍分组管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it)
}
}
}
private fun initMenu() {
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.inflateMenu(R.menu.book_group_manage)
binding.toolBar.menu.applyTint(requireContext())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> {
if (appDb.bookGroupDao.canAddGroup) {
showDialogFragment(GroupEditDialog())
} else {
toastOnUi("分组已达上限(64个)")
}
}
}
return true
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<BookGroup, ItemBookGroupManageBinding>(context),
ItemTouchCallback.Callback {
private var isMoved = false
override fun getViewBinding(parent: ViewGroup): ItemBookGroupManageBinding {
return ItemBookGroupManageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookGroupManageBinding,
item: BookGroup,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
tvGroup.text = item.getManageName(context)
swShow.isChecked = item.show
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookGroupManageBinding) {
binding.run {
tvEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let { bookGroup ->
showDialogFragment(
GroupEditDialog(bookGroup)
)
}
}
swShow.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
viewModel.upGroup(it.copy(show = isChecked))
}
}
}
}
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
viewModel.upGroup(*getItems().toTypedArray())
}
isMoved = false
}
}
}
@@ -0,0 +1,168 @@
package io.legato.kazusa.ui.book.group
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.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.data.entities.BookGroup
import io.legato.kazusa.databinding.DialogBookGroupPickerBinding
import io.legato.kazusa.databinding.ItemGroupSelectBinding
//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.ItemTouchCallback
import io.legato.kazusa.ui.widget.recycler.VerticalDivider
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class GroupSelectDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_book_group_picker),
Toolbar.OnMenuItemClickListener {
constructor(groupId: Long, requestCode: Int = -1) : this() {
arguments = Bundle().apply {
putLong("groupId", groupId)
putInt("requestCode", requestCode)
}
}
private val binding by viewBinding(DialogBookGroupPickerBinding::bind)
private var requestCode: Int = -1
private val viewModel: GroupViewModel by viewModels()
private val adapter by lazy { GroupAdapter(requireContext()) }
private val callBack get() = (activity as? CallBack)
private var groupId: Long = 0
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
arguments?.let {
groupId = it.getLong("groupId")
requestCode = it.getInt("requestCode", -1)
}
initView()
initData()
}
private fun initView() {
binding.toolBar.title = getString(R.string.group_select)
binding.toolBar.inflateMenu(R.menu.book_group_manage)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
//binding.tvOk.setTextColor(requireContext().accentColor)
binding.tvOk.setOnClickListener {
callBack?.upGroup(requestCode, groupId)
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowSelect().conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> showDialogFragment(
GroupEditDialog()
)
}
return true
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<BookGroup, ItemGroupSelectBinding>(context),
ItemTouchCallback.Callback {
private var isMoved: Boolean = false
override fun getViewBinding(parent: ViewGroup): ItemGroupSelectBinding {
return ItemGroupSelectBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemGroupSelectBinding,
item: BookGroup,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
cbGroup.text = item.groupName
cbGroup.isChecked = (groupId and item.groupId) > 0
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupSelectBinding) {
binding.run {
cbGroup.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
groupId = if (isChecked) {
groupId + it.groupId
} else {
groupId - it.groupId
}
}
}
}
tvEdit.setOnClickListener {
showDialogFragment(
GroupEditDialog(getItem(holder.layoutPosition))
)
}
}
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
viewModel.upGroup(*getItems().toTypedArray())
}
isMoved = false
}
}
interface CallBack {
fun upGroup(requestCode: Int, groupId: Long)
}
}
@@ -0,0 +1,52 @@
package io.legato.kazusa.ui.book.group
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookGroup
class GroupViewModel(application: Application) : BaseViewModel(application) {
fun upGroup(vararg bookGroup: BookGroup, finally: (() -> Unit)? = null) {
execute {
appDb.bookGroupDao.update(*bookGroup)
}.onFinally {
finally?.invoke()
}
}
fun addGroup(
groupName: String,
bookSort: Int,
enableRefresh: Boolean,
cover: String?,
finally: () -> Unit
) {
execute {
val groupId = appDb.bookGroupDao.getUnusedId()
val bookGroup = BookGroup(
groupId = groupId,
groupName = groupName,
cover = cover,
bookSort = bookSort,
enableRefresh = enableRefresh,
order = appDb.bookGroupDao.maxOrder.plus(1)
)
appDb.bookGroupDao.getByID(groupId) ?: appDb.bookDao.removeGroup(groupId)
appDb.bookGroupDao.insert(bookGroup)
}.onFinally {
finally()
}
}
fun delGroup(bookGroup: BookGroup, finally: () -> Unit) {
execute {
appDb.bookGroupDao.delete(bookGroup)
appDb.bookDao.removeGroup(bookGroup.groupId)
}.onFinally {
finally()
}
}
}
@@ -0,0 +1,177 @@
package io.legato.kazusa.ui.book.import
import android.os.Bundle
import android.view.MotionEvent
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.ViewModel
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ActivityImportBookBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.shouldHideSoftInput
import io.legato.kazusa.utils.startActivityForBook
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
abstract class BaseImportBookActivity<VM : ViewModel> :
VMBaseActivity<ActivityImportBookBinding, VM>() {
final override val binding by viewBinding(ActivityImportBookBinding::inflate)
private var localBookTreeSelectListener: ((Boolean) -> Unit)? = null
protected val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
AppConfig.defaultBookTreeUri = treeUri.toString()
localBookTreeSelectListener?.invoke(true)
} ?: localBookTreeSelectListener?.invoke(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initSearchView()
}
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)
}
/**
* 设置书籍保存位置
*/
protected suspend fun setBookStorage() = suspendCoroutine { block ->
localBookTreeSelectListener = {
localBookTreeSelectListener = null
block.resume(it)
}
//测试书籍保存位置是否设置
if (!AppConfig.defaultBookTreeUri.isNullOrBlank()) {
localBookTreeSelectListener = null
block.resume(true)
return@suspendCoroutine
}
//测试读写??
val storageHelp = String(assets.open("storageHelp.md").readBytes())
val hint = getString(R.string.select_book_folder)
alert(hint, storageHelp) {
okButton {
localBookTreeSelect.launch {
title = hint
}
}
cancelButton {
localBookTreeSelectListener = null
block.resume(false)
}
onCancelled {
localBookTreeSelectListener = null
block.resume(false)
}
}
}
abstract fun onSearchTextChange(newText: String?)
protected fun startReadBook(book: Book) {
startActivityForBook(book)
}
protected fun onArchiveFileClick(fileDoc: FileDoc) {
val fileNames = ArchiveUtils.getArchiveFilesName(fileDoc) {
it.matches(AppPattern.bookFileRegex)
}
if (fileNames.size == 1) {
val name = fileNames[0]
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
} else {
showSelectBookReadAlert(fileDoc, fileNames)
}
}
private fun showSelectBookReadAlert(fileDoc: FileDoc, fileNames: List<String>) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
return
}
selector(
R.string.start_read,
fileNames
) { _, name, _ ->
appDb.bookDao.getBookByFileName(name)?.let {
startReadBook(it)
} ?: showImportAlert(fileDoc, name)
}
}
/* 添加压缩包内指定文件到书架 */
private inline fun addArchiveToBookShelf(
fileDoc: FileDoc,
fileName: String,
onSuccess: (Book) -> Unit
) {
LocalBook.importArchiveFile(fileDoc.uri, fileName) {
it.contains(fileName)
}.firstOrNull()?.run {
onSuccess.invoke(this)
}
}
/* 提示是否重新导入所点击的压缩文件 */
private fun showImportAlert(fileDoc: FileDoc, fileName: String) {
alert(
R.string.draw,
R.string.no_book_found_bookshelf
) {
okButton {
addArchiveToBookShelf(fileDoc, fileName) {
startReadBook(it)
}
}
noButton()
}
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
onSearchTextChange(newText)
return false
}
})
}
}
@@ -0,0 +1,14 @@
package io.legato.kazusa.ui.book.import.local
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.FileDoc
data class ImportBook(
val file: FileDoc,
var isOnBookShelf: Boolean = !file.isDir && LocalBook.isOnBookShelf(file.name)
) {
val name get() = file.name
val isDir get() = file.isDir
val size get() = file.size
val lastModified get() = file.lastModified
}
@@ -0,0 +1,319 @@
package io.legato.kazusa.ui.book.import.local
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.data.appDb
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.permission.Permissions
import io.legato.kazusa.lib.permission.PermissionsCompat
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.ui.book.import.BaseImportBookActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.isUri
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.putPrefInt
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* 导入本地书籍界面
*/
class ImportBookActivity : BaseImportBookActivity<ImportBookViewModel>(),
PopupMenu.OnMenuItemClickListener,
ImportBookAdapter.CallBack,
SelectActionBar.CallBack {
override val viewModel by viewModels<ImportBookViewModel>()
private val adapter by lazy { ImportBookAdapter(this, this) }
private var scanDocJob: Job? = null
private val selectFolder = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
AppConfig.importBookPath = uri.toString()
initRootDoc(true)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.local_book)
onBackPressedDispatcher.addCallback(this) {
if (!goBackDir()) {
finish()
}
}
lifecycleScope.launch {
initView()
initEvent()
if (setBookStorage() && AppConfig.importBookPath.isNullOrBlank()) {
AppConfig.importBookPath = AppConfig.defaultBookTreeUri
}
initData()
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.import_book, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_sort_name)?.isChecked = viewModel.sort == 0
menu.findItem(R.id.menu_sort_size)?.isChecked = viewModel.sort == 1
menu.findItem(R.id.menu_sort_time)?.isChecked = viewModel.sort == 2
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_select_folder -> selectFolder.launch()
R.id.menu_scan_folder -> scanFolder()
R.id.menu_import_file_name -> alertImportFileName()
R.id.menu_sort_name -> upSort(0)
R.id.menu_sort_size -> upSort(1)
R.id.menu_sort_time -> upSort(2)
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_del_selection -> viewModel.deleteDoc(adapter.selected) {
adapter.removeSelection()
}
}
return false
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
override fun revertSelection() {
adapter.revertSelection()
}
@SuppressLint("NotifyDataSetChanged")
override fun onClickSelectBarMainAction() {
viewModel.addToBookshelf(adapter.selected) {
adapter.selected.forEach {
it.isOnBookShelf = true
}
adapter.selected.clear()
adapter.notifyDataSetChanged()
}
}
private fun initView() {
//binding.layTop.setBackgroundColor(backgroundColor)
binding.tvEmptyMsg.setText(R.string.empty_msg_import_book)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15)
binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf)
binding.selectActionBar.inflateMenu(R.menu.import_book_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun initEvent() {
binding.tvGoBack.setOnClickListener {
goBackDir()
}
}
private fun initData() {
viewModel.dataFlowStart = {
initRootDoc()
}
lifecycleScope.launch {
viewModel.dataFlow.conflate().collect { docs ->
adapter.setItems(docs)
}
}
}
private fun initRootDoc(changedFolder: Boolean = false) {
if (viewModel.rootDoc != null && !changedFolder) {
upPath()
} else {
val lastPath = AppConfig.importBookPath
if (lastPath.isNullOrBlank()) {
binding.tvEmptyMsg.visible()
selectFolder.launch()
} else {
val rootUri = if (lastPath.isUri()) {
Uri.parse(lastPath)
} else {
Uri.fromFile(File(lastPath))
}
when {
rootUri.isContentScheme() -> {
kotlin.runCatching {
val doc = DocumentFile.fromTreeUri(this, rootUri)
if (doc == null || doc.name.isNullOrEmpty()) {
binding.tvEmptyMsg.visible()
selectFolder.launch()
} else {
viewModel.subDocs.clear()
viewModel.rootDoc = FileDoc.fromDocumentFile(doc)
upDocs(viewModel.rootDoc!!)
}
}.onFailure {
binding.tvEmptyMsg.visible()
selectFolder.launch()
}
}
else -> initRootPath(rootUri.path!!)
}
}
}
}
private fun initRootPath(path: String) {
binding.tvEmptyMsg.visible()
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
kotlin.runCatching {
viewModel.rootDoc = FileDoc.fromFile(File(path))
viewModel.subDocs.clear()
upPath()
}.onFailure {
binding.tvEmptyMsg.visible()
selectFolder.launch()
}
}
.request()
}
private fun upSort(sort: Int) {
viewModel.sort = sort
putPrefInt(PreferKey.localBookImportSort, sort)
if (scanDocJob?.isActive != true) {
viewModel.dataCallback?.upAdapter()
}
}
@Synchronized
private fun upPath() {
binding.tvGoBack.isEnabled = viewModel.subDocs.isNotEmpty()
viewModel.rootDoc?.let {
scanDocJob?.cancel()
upDocs(it)
}
}
private fun upDocs(rootDoc: FileDoc) {
binding.tvEmptyMsg.gone()
var path = rootDoc.name + File.separator
var lastDoc = rootDoc
for (doc in viewModel.subDocs) {
lastDoc = doc
path = path + doc.name + File.separator
}
binding.tvPath.text = path
adapter.selected.clear()
adapter.clearItems()
viewModel.loadDoc(lastDoc)
}
/**
* 扫描当前文件夹及所有子文件夹
*/
private fun scanFolder() {
viewModel.rootDoc?.let { doc ->
adapter.clearItems()
val lastDoc = viewModel.subDocs.lastOrNull() ?: doc
binding.refreshProgressBar.isAutoLoading = true
scanDocJob?.cancel()
scanDocJob = lifecycleScope.launch(IO) {
viewModel.scanDoc(lastDoc)
withContext(Main) {
binding.refreshProgressBar.isAutoLoading = false
}
}
}
}
private fun alertImportFileName() {
alert(R.string.import_file_name) {
setMessage("""使用js处理文件名变量src,将书名作者分别赋值到变量name author""")
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "js"
editView.setText(AppConfig.bookImportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookImportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
@Synchronized
override fun nextDoc(fileDoc: FileDoc) {
viewModel.subDocs.add(fileDoc)
upPath()
}
@Synchronized
private fun goBackDir(): Boolean {
return if (viewModel.subDocs.isNotEmpty()) {
viewModel.subDocs.removeAt(viewModel.subDocs.lastIndex)
upPath()
true
} else {
false
}
}
override fun onSearchTextChange(newText: String?) {
viewModel.updateCallBackFlow(newText)
}
override fun upCountView() {
binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount)
}
override fun startRead(fileDoc: FileDoc) {
if (!ArchiveUtils.isArchive(fileDoc.name)) {
appDb.bookDao.getBookByFileName(fileDoc.name)?.let {
val filePath = fileDoc.toString()
if (it.bookUrl != filePath) {
it.bookUrl = filePath
appDb.bookDao.insert(it)
}
startReadBook(it)
}
} else {
onArchiveFileClick(fileDoc)
}
}
}
@@ -0,0 +1,141 @@
package io.legato.kazusa.ui.book.import.local
import android.annotation.SuppressLint
import android.content.Context
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.constant.AppConst
import io.legato.kazusa.databinding.ItemImportBookBinding
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
class ImportBookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<ImportBook, ItemImportBookBinding>(context) {
val selected = hashSetOf<ImportBook>()
var checkableCount = 0
override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding {
return ItemImportBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
upCheckableCount()
}
override fun convert(
holder: ItemViewHolder,
binding: ItemImportBookBinding,
item: ImportBook,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
if (item.isDir) {
ivIcon.setImageResource(R.drawable.ic_folder)
ivIcon.visible()
cbSelect.invisible()
llBrief.gone()
cbSelect.isChecked = false
} else {
if (item.isOnBookShelf) {
ivIcon.setImageResource(R.drawable.ic_book_has)
ivIcon.visible()
cbSelect.invisible()
} else {
ivIcon.invisible()
cbSelect.visible()
}
llBrief.visible()
tvTag.text = item.name.substringAfterLast(".")
tvSize.text = ConvertUtils.formatFileSize(item.size)
tvDate.text = AppConst.dateFormat.format(item.lastModified)
cbSelect.isChecked = selected.contains(item)
}
tvName.text = item.name
} else {
cbSelect.isChecked = selected.contains(item)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.isDir) {
callBack.nextDoc(it.file)
} else if (!it.isOnBookShelf) {
if (!selected.contains(it)) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(holder.layoutPosition, true)
callBack.upCountView()
} else {
/* 点击开始阅读 */
callBack.startRead(it.file)
}
}
}
}
private fun upCheckableCount() {
checkableCount = 0
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
checkableCount++
}
}
callBack.upCountView()
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
selected.add(it)
}
}
} else {
selected.clear()
}
notifyDataSetChanged()
callBack.upCountView()
}
fun revertSelection() {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
if (selected.contains(it)) {
selected.remove(it)
} else {
selected.add(it)
}
}
}
notifyItemRangeChanged(0, itemCount, true)
callBack.upCountView()
}
fun removeSelection() {
for (i in getItems().lastIndex downTo 0) {
if (getItem(i) in selected) {
removeItem(i)
}
}
}
interface CallBack {
fun nextDoc(fileDoc: FileDoc)
fun upCountView()
fun startRead(fileDoc: FileDoc)
}
}
@@ -0,0 +1,181 @@
package io.legato.kazusa.ui.book.import.local
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern.archiveFileRegex
import io.legato.kazusa.constant.AppPattern.bookFileRegex
import io.legato.kazusa.constant.PreferKey
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.AlphanumComparator
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.delete
import io.legato.kazusa.utils.getPrefInt
import io.legato.kazusa.utils.list
import io.legato.kazusa.utils.mapParallel
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext
import java.util.Collections
class ImportBookViewModel(application: Application) : BaseViewModel(application) {
var rootDoc: FileDoc? = null
val subDocs = arrayListOf<FileDoc>()
var sort = context.getPrefInt(PreferKey.localBookImportSort)
var dataCallback: DataCallback? = null
var dataFlowStart: (() -> Unit)? = null
var filterKey: String? = null
val dataFlow = callbackFlow<List<ImportBook>> {
val list = Collections.synchronizedList(ArrayList<ImportBook>())
dataCallback = object : DataCallback {
override fun setItems(fileDocs: List<FileDoc>) {
list.clear()
fileDocs.mapTo(list) {
ImportBook(it)
}
trySend(list)
}
override fun addItems(fileDocs: List<FileDoc>) {
fileDocs.mapTo(list) {
ImportBook(it)
}
trySend(list)
}
override fun clear() {
list.clear()
trySend(emptyList())
}
override fun upAdapter() {
trySend(list)
}
}
withContext(Main) {
dataFlowStart?.invoke()
}
awaitClose {
dataCallback = null
}
}.map { docList ->
val docList = docList.toList()
val filterKey = filterKey
val skipFilter = filterKey.isNullOrBlank()
val comparator = when (sort) {
2 -> compareBy<ImportBook>({ !it.isDir }, { -it.lastModified })
1 -> compareBy({ !it.isDir }, { -it.size })
else -> compareBy { !it.isDir }
} then compareBy(AlphanumComparator) { it.name }
docList.asSequence().filter {
skipFilter || it.name.contains(filterKey)
}.sortedWith(comparator).toList()
}.flowOn(IO)
fun addToBookshelf(bookList: HashSet<ImportBook>, finally: () -> Unit) {
execute {
val fileUris = bookList.map {
it.file.uri
}
LocalBook.importFiles(fileUris)
}.onError {
context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
AppLog.put("添加书架失败\n${it.localizedMessage}", it)
}.onSuccess {
context.toastOnUi("添加书架成功")
}.onFinally {
finally.invoke()
}
}
fun deleteDoc(bookList: HashSet<ImportBook>, finally: () -> Unit) {
execute {
bookList.forEach {
it.file.delete()
}
}.onFinally {
finally.invoke()
}
}
fun loadDoc(fileDoc: FileDoc) {
execute {
val docList = fileDoc.list { item ->
when {
item.name.startsWith(".") -> false
item.isDir -> true
else -> item.name.matches(bookFileRegex) || item.name.matches(archiveFileRegex)
}
}
dataCallback?.setItems(docList!!)
}.onError {
context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
}
}
suspend fun scanDoc(fileDoc: FileDoc) {
dataCallback?.clear()
val channel = Channel<FileDoc>(UNLIMITED)
var n = 1
channel.trySend(fileDoc)
val list = arrayListOf<FileDoc>()
channel.consumeAsFlow()
.mapParallel(16) { fileDoc ->
fileDoc.list()!!
}.onEach { fileDocs ->
n--
list.clear()
fileDocs.forEach {
if (it.isDir) {
n++
channel.trySend(it)
} else if (it.name.matches(bookFileRegex)
|| it.name.matches(archiveFileRegex)
) {
list.add(it)
}
}
dataCallback?.addItems(list)
}.takeWhile {
n > 0
}.catch {
context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
}.collect()
}
fun updateCallBackFlow(filterKey: String?) {
this.filterKey = filterKey
dataCallback?.upAdapter()
}
interface DataCallback {
fun setItems(fileDocs: List<FileDoc>)
fun addItems(fileDocs: List<FileDoc>)
fun clear()
fun upAdapter()
}
}
@@ -0,0 +1,259 @@
package io.legato.kazusa.ui.book.import.remote
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legato.kazusa.R
import io.legato.kazusa.data.appDb
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
//import io.legado.app.lib.theme.backgroundColor
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.import.BaseImportBookActivity
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.showHelp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
import java.io.File
/**
* 展示远程书籍
*/
class RemoteBookActivity : BaseImportBookActivity<RemoteBookViewModel>(),
RemoteBookAdapter.CallBack,
SelectActionBar.CallBack,
ServersDialog.Callback {
override val viewModel by viewModels<RemoteBookViewModel>()
private val adapter by lazy { RemoteBookAdapter(this, this) }
private var groupMenu: SubMenu? = null
override fun onActivityCreated(savedInstanceState: Bundle?) {
searchView.queryHint = getString(R.string.screen) + " • " + getString(R.string.remote_book)
onBackPressedDispatcher.addCallback(this) {
if (!goBackDir()) {
finish()
}
}
lifecycleScope.launch {
if (!setBookStorage()) {
finish()
return@launch
}
initView()
initEvent()
launch {
viewModel.dataFlow.conflate().collect { sortedRemoteBooks ->
binding.refreshProgressBar.isAutoLoading = false
binding.tvEmptyMsg.isGone = sortedRemoteBooks.isNotEmpty()
adapter.setItems(sortedRemoteBooks)
delay(500)
}
}
viewModel.initData {
upPath()
}
}
}
override fun observeLiveBus() {
viewModel.permissionDenialLiveData.observe(this) {
localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
}
}
}
private fun initView() {
//binding.layTop.setBackgroundColor(backgroundColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.selectActionBar.setMainActionText(R.string.add_to_bookshelf)
binding.selectActionBar.setCallBack(this)
if (!LocalConfig.webDavBookHelpVersionIsLast) {
showHelp("webDavBookHelp")
}
}
private fun sortCheck(sortKey: RemoteBookSort) {
if (viewModel.sortKey == sortKey) {
viewModel.sortAscending = !viewModel.sortAscending
} else {
viewModel.sortAscending = true
viewModel.sortKey = sortKey
}
}
private fun initEvent() {
binding.tvGoBack.setOnClickListener {
goBackDir()
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_remote, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_refresh -> upPath()
R.id.menu_server_config -> showDialogFragment<ServersDialog>()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("webDavBookHelp")
R.id.menu_sort_name -> {
item.isChecked = true
sortCheck(RemoteBookSort.Name)
upPath()
}
R.id.menu_sort_time -> {
item.isChecked = true
sortCheck(RemoteBookSort.Default)
upPath()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
groupMenu = menu.findItem(R.id.menu_sort)?.subMenu
groupMenu?.setGroupCheckable(R.id.menu_group_sort, true, true)
groupMenu?.findItem(R.id.menu_sort_name)?.isChecked =
viewModel.sortKey == RemoteBookSort.Name
groupMenu?.findItem(R.id.menu_sort_time)?.isChecked =
viewModel.sortKey == RemoteBookSort.Default
return super.onPrepareOptionsMenu(menu)
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
@SuppressLint("NotifyDataSetChanged")
override fun onClickSelectBarMainAction() {
binding.refreshProgressBar.isAutoLoading = true
viewModel.addToBookshelf(adapter.selected) {
adapter.selected.clear()
adapter.notifyDataSetChanged()
binding.refreshProgressBar.isAutoLoading = false
}
}
private fun goBackDir(): Boolean {
if (viewModel.dirList.isEmpty()) {
return false
}
viewModel.dirList.removeLastOrNull()
upPath()
return true
}
private fun upPath() {
binding.tvGoBack.isEnabled = viewModel.dirList.isNotEmpty()
var path = if (viewModel.isDefaultWebdav) {
"books" + File.separator
} else {
File.separator
}
viewModel.dirList.forEach {
path = path + it.filename + File.separator
}
binding.tvPath.text = path
viewModel.dataCallback?.clear()
adapter.selected.clear()
viewModel.loadRemoteBookList(
viewModel.dirList.lastOrNull()?.path
) {
binding.refreshProgressBar.isAutoLoading = it
}
}
override fun openDir(remoteBook: RemoteBook) {
viewModel.dirList.add(remoteBook)
upPath()
}
override fun upCountView() {
binding.selectActionBar.upCountView(adapter.selected.size, adapter.checkableCount)
}
override fun onDialogDismiss(tag: String) {
viewModel.initData {
upPath()
}
}
override fun onSearchTextChange(newText: String?) {
viewModel.updateCallBackFlow(newText)
}
private fun showRemoteBookDownloadAlert(
remoteBook: RemoteBook,
onDownloadFinish: (() -> Unit)? = null
) {
alert(
R.string.draw,
R.string.archive_not_found
) {
okButton {
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
onDownloadFinish?.invoke()
}
}
noButton()
}
}
override fun startRead(remoteBook: RemoteBook) {
val downloadFileName = remoteBook.filename
if (!ArchiveUtils.isArchive(downloadFileName)) {
appDb.bookDao.getBookByFileName(downloadFileName)?.let {
startReadBook(it)
}
} else {
AppConfig.defaultBookTreeUri ?: return
val downloadArchiveFileDoc = FileDoc.fromUri(Uri.parse(AppConfig.defaultBookTreeUri), true)
.find(downloadFileName)
if (downloadArchiveFileDoc == null) {
showRemoteBookDownloadAlert(remoteBook) {
startRead(remoteBook)
}
} else {
onArchiveFileClick(downloadArchiveFileDoc)
}
}
}
override fun addToBookShelfAgain(remoteBook: RemoteBook) {
alert(getString(R.string.sure), "是否重新加入书架?") {
yesButton {
binding.refreshProgressBar.isAutoLoading = true
viewModel.addToBookshelf(hashSetOf(remoteBook)) {
binding.refreshProgressBar.isAutoLoading = false
}
}
noButton()
}
}
}
@@ -0,0 +1,157 @@
package io.legato.kazusa.ui.book.import.remote
import android.annotation.SuppressLint
import android.content.Context
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.constant.AppConst
import io.legato.kazusa.databinding.ItemImportBookBinding
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.invisible
import io.legato.kazusa.utils.visible
/**
* 适配器
* @author qianfanguojin
*/
class RemoteBookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RemoteBook, ItemImportBookBinding>(context) {
var selected = hashSetOf<RemoteBook>()
var checkableCount = 0
override fun getViewBinding(parent: ViewGroup): ItemImportBookBinding {
return ItemImportBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
upCheckableCount()
}
/**
* 绑定RecycleView 中每一个项的视图和数据
*/
override fun convert(
holder: ItemViewHolder,
binding: ItemImportBookBinding,
item: RemoteBook,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
if (item.isDir) {
ivIcon.setImageResource(R.drawable.ic_folder)
ivIcon.visible()
cbSelect.invisible()
llBrief.gone()
cbSelect.isChecked = false
} else {
if (item.isOnBookShelf) {
ivIcon.setImageResource(R.drawable.ic_book_has)
ivIcon.visible()
cbSelect.invisible()
} else {
ivIcon.invisible()
cbSelect.visible()
}
llBrief.visible()
tvTag.text = item.contentType
tvSize.text = ConvertUtils.formatFileSize(item.size)
tvDate.text = AppConst.dateFormat.format(item.lastModify)
cbSelect.isChecked = selected.contains(item)
}
tvName.text = item.filename
} else {
cbSelect.isChecked = selected.contains(item)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemImportBookBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
if (it.isDir) {
callBack.openDir(it)
} else if (!it.isOnBookShelf) {
if (!selected.contains(it)) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(holder.layoutPosition, true)
callBack.upCountView()
} else {
/* 点击开始阅读 */
callBack.startRead(it)
}
}
}
holder.itemView.setOnLongClickListener {
getItem(holder.layoutPosition)?.let { remoteBook ->
if (remoteBook.isOnBookShelf) {
callBack.addToBookShelfAgain(remoteBook)
}
}
true
}
}
private fun upCheckableCount() {
checkableCount = 0
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
checkableCount++
}
}
callBack.upCountView()
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
selected.add(it)
}
}
} else {
selected.clear()
}
notifyDataSetChanged()
callBack.upCountView()
}
fun revertSelection() {
getItems().forEach {
if (!it.isDir && !it.isOnBookShelf) {
if (selected.contains(it)) {
selected.remove(it)
} else {
selected.add(it)
}
}
}
notifyItemRangeChanged(0, itemCount, true)
callBack.upCountView()
}
fun removeSelection() {
for (i in getItems().lastIndex downTo 0) {
if (getItem(i) in selected) {
removeItem(i)
}
}
}
interface CallBack {
fun openDir(remoteBook: RemoteBook)
fun upCountView()
fun startRead(remoteBook: RemoteBook)
fun addToBookShelfAgain(remoteBook: RemoteBook)
}
}
@@ -0,0 +1,5 @@
package io.legato.kazusa.ui.book.import.remote
enum class RemoteBookSort {
Default, Name
}
@@ -0,0 +1,174 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.webdav.Authorization
import io.legato.kazusa.model.analyzeRule.CustomUrl
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.remote.RemoteBook
import io.legato.kazusa.model.remote.RemoteBookWebDav
import io.legato.kazusa.utils.AlphanumComparator
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.util.Collections
class RemoteBookViewModel(application: Application) : BaseViewModel(application) {
var sortKey = RemoteBookSort.Default
var sortAscending = false
val dirList = arrayListOf<RemoteBook>()
val permissionDenialLiveData = MutableLiveData<Int>()
var dataCallback: DataCallback? = null
val dataFlow = callbackFlow<List<RemoteBook>> {
val list = Collections.synchronizedList(ArrayList<RemoteBook>())
dataCallback = object : DataCallback {
override fun setItems(remoteFiles: List<RemoteBook>) {
list.clear()
list.addAll(remoteFiles)
trySend(list)
}
override fun addItems(remoteFiles: List<RemoteBook>) {
list.addAll(remoteFiles)
trySend(list)
}
override fun clear() {
list.clear()
trySend(emptyList())
}
override fun screen(key: String?) {
if (key.isNullOrBlank()) {
trySend(list)
} else {
trySend(
list.filter { it.filename.contains(key) }
)
}
}
}
awaitClose {
dataCallback = null
}
}.map { list ->
if (sortAscending) when (sortKey) {
RemoteBookSort.Name -> list.sortedWith(compareBy<RemoteBook> { !it.isDir }
then compareBy(AlphanumComparator) { it.filename })
else -> list.sortedWith(compareBy({ !it.isDir }, { it.lastModify }))
} else when (sortKey) {
RemoteBookSort.Name -> list.sortedWith { o1, o2 ->
val compare = -compareValues(o1.isDir, o2.isDir)
if (compare == 0) {
return@sortedWith -AlphanumComparator.compare(o1.filename, o2.filename)
}
return@sortedWith compare
}
else -> list.sortedWith { o1, o2 ->
val compare = -compareValues(o1.isDir, o2.isDir)
if (compare == 0) {
return@sortedWith -compareValues(o1.lastModify, o2.lastModify)
}
return@sortedWith compare
}
}
}.flowOn(Dispatchers.IO)
private var remoteBookWebDav: RemoteBookWebDav? = null
var isDefaultWebdav = false
fun initData(onSuccess: () -> Unit) {
execute {
isDefaultWebdav = false
appDb.serverDao.get(AppConfig.remoteServerId)?.getWebDavConfig()?.let {
val authorization = Authorization(it)
remoteBookWebDav = RemoteBookWebDav(it.url, authorization, AppConfig.remoteServerId)
return@execute
}
isDefaultWebdav = true
remoteBookWebDav = AppWebDav.defaultBookWebDav
?: throw NoStackTraceException("webDav没有配置")
}.onError {
context.toastOnUi("初始化webDav出错:${it.localizedMessage}")
}.onSuccess {
onSuccess.invoke()
}
}
fun loadRemoteBookList(path: String?, loadCallback: (loading: Boolean) -> Unit) {
executeLazy {
val bookWebDav = remoteBookWebDav
?: throw NoStackTraceException("没有配置webDav")
dataCallback?.clear()
val url = path ?: bookWebDav.rootBookUrl
val bookList = bookWebDav.getRemoteBookList(url)
dataCallback?.setItems(bookList)
}.onError {
AppLog.put("获取webDav书籍出错\n${it.localizedMessage}", it)
context.toastOnUi("获取webDav书籍出错\n${it.localizedMessage}")
}.onStart {
loadCallback.invoke(true)
}.onFinally {
loadCallback.invoke(false)
}.start()
}
fun addToBookshelf(remoteBooks: HashSet<RemoteBook>, finally: () -> Unit) {
execute {
val bookWebDav = remoteBookWebDav
?: throw NoStackTraceException("没有配置webDav")
remoteBooks.forEach { remoteBook ->
val downloadBookUri = bookWebDav.downloadRemoteBook(remoteBook)
LocalBook.importFiles(downloadBookUri).forEach { book ->
book.origin = BookType.webDavTag + CustomUrl(remoteBook.path)
.putAttribute("serverID", bookWebDav.serverID)
.toString()
book.save()
}
remoteBook.isOnBookShelf = true
}
}.onError {
AppLog.put("导入出错\n${it.localizedMessage}", it)
context.toastOnUi("导入出错\n${it.localizedMessage}")
if (it is SecurityException) {
permissionDenialLiveData.postValue(1)
}
}.onFinally {
finally.invoke()
}
}
fun updateCallBackFlow(filterKey: String?) {
dataCallback?.screen(filterKey)
}
interface DataCallback {
fun setItems(remoteFiles: List<RemoteBook>)
fun addItems(remoteFiles: List<RemoteBook>)
fun clear()
fun screen(key: String?)
}
}
@@ -0,0 +1,131 @@
package io.legato.kazusa.ui.book.import.remote
import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.data.entities.rule.RowUi
import io.legato.kazusa.databinding.DialogWebdavServerBinding
import io.legato.kazusa.databinding.ItemSourceEditBinding
//import io.legado.app.lib.theme.primaryColor
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import org.json.JSONObject
class ServerConfigDialog() : BaseDialogFragment(R.layout.dialog_webdav_server, true),
Toolbar.OnMenuItemClickListener {
constructor(id: Long) : this() {
arguments = Bundle().apply {
putLong("id", id)
}
}
private val binding by viewBinding(DialogWebdavServerBinding::bind)
private val viewModel by viewModels<ServerConfigViewModel>()
private val webDavServerUi = listOf(
RowUi("url"),
RowUi("username"),
RowUi("password", RowUi.Type.password)
)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//inding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.inflateMenu(R.menu.server_config)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
viewModel.init(arguments?.getLong("id")) {
upConfigView(viewModel.mServer)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_save -> getServer().let {
viewModel.save(it) {
dismissAllowingStateLoss()
}
}
}
return true
}
private fun upConfigView(server: Server?) {
binding.etName.setText(server?.name)
binding.spType.setSelection(
when (server?.type) {
else -> 0
}
)
when (server?.type) {
else -> upWebDavServerUi(server?.getConfigJsonObject())
}
}
private fun upWebDavServerUi(config: JSONObject?) {
webDavServerUi.forEachIndexed { index, rowUi ->
when (rowUi.type) {
RowUi.Type.text -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.setText(config?.getString(rowUi.name))
}
RowUi.Type.password -> ItemSourceEditBinding.inflate(
layoutInflater,
binding.root,
false
).let {
binding.flexbox.addView(it.root)
it.root.id = index + 1000
it.textInputLayout.hint = rowUi.name
it.editText.inputType =
InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
it.editText.setText(config?.getString(rowUi.name))
}
}
}
}
private fun getServer(): Server {
val server = viewModel.mServer?.copy() ?: Server()
server.name = binding.etName.text.toString()
server.type = when (binding.spType.selectedItemPosition) {
else -> Server.TYPE.WEBDAV
}
server.config = when (server.type) {
else -> GSON.toJson(getWebDavConfig())
}
return server
}
private fun getWebDavConfig(): HashMap<String, String> {
val data = hashMapOf<String, String>()
webDavServerUi.forEachIndexed { index, rowUi ->
val rowView = binding.root.findViewById<View>(index + 1000)
ItemSourceEditBinding.bind(rowView).editText.text?.let {
data[rowUi.name] = it.toString()
}
}
return data
}
}
@@ -0,0 +1,41 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.utils.toastOnUi
class ServerConfigViewModel(application: Application): BaseViewModel(application) {
var mServer: Server? = null
fun init(id: Long?, onSuccess: () -> Unit) {
//mServer不为空可能是旋转屏幕界面重新创建,不用更新数据
if (mServer != null) return
execute {
mServer = if (id != null) {
appDb.serverDao.get(id)
} else {
Server()
}
}.onSuccess {
onSuccess.invoke()
}
}
fun save(server: Server, onSuccess: () -> Unit) {
execute {
mServer?.let {
appDb.serverDao.delete(it)
}
mServer = server
appDb.serverDao.insert(server)
}.onSuccess {
onSuccess.invoke()
}.onError {
context.toastOnUi("保存出错\n${it.localizedMessage}")
}
}
}
@@ -0,0 +1,167 @@
package io.legato.kazusa.ui.book.import.remote
import android.content.Context
import android.content.DialogInterface
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.lifecycle.lifecycleScope
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.constant.AppConst.DEFAULT_WEBDAV_ID
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
import io.legato.kazusa.databinding.DialogRecyclerViewBinding
import io.legato.kazusa.databinding.ItemServerSelectBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.lib.dialogs.alert
//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.setLayout
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 服务器配置
*/
class ServersDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
val binding by viewBinding(DialogRecyclerViewBinding::bind)
val viewModel by viewModels<ServersViewModel>()
private val callback get() = (activity as? Callback)
private val adapter by lazy { ServersAdapter(requireContext()) }
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)
binding.toolBar.setTitle(R.string.server_config)
initView()
initData()
}
private fun initView() {
binding.toolBar.inflateMenu(R.menu.servers)
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = adapter
binding.tvFooterLeft.text = getString(R.string.text_default)
binding.tvFooterLeft.visible()
binding.tvFooterLeft.setOnClickListener {
AppConfig.remoteServerId = DEFAULT_WEBDAV_ID
dismissAllowingStateLoss()
}
binding.tvCancel.visible()
binding.tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvOk.visible()
binding.tvOk.setOnClickListener {
AppConfig.remoteServerId = adapter.selectServerId
dismissAllowingStateLoss()
}
}
private fun initData() {
lifecycleScope.launch {
appDb.serverDao.observeAll().catch {
AppLog.put("服务器配置界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> showDialogFragment(ServerConfigDialog())
}
return true
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
callback?.onDialogDismiss("serversDialog")
}
inner class ServersAdapter(context: Context) :
RecyclerAdapter<Server, ItemServerSelectBinding>(context) {
var selectServerId: Long = AppConfig.remoteServerId
override fun getViewBinding(parent: ViewGroup): ItemServerSelectBinding {
return ItemServerSelectBinding.inflate(inflater, parent, false)
}
override fun registerListener(holder: ItemViewHolder, binding: ItemServerSelectBinding) {
binding.rbServer.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed && isChecked) {
selectServerId = getItemByLayoutPosition(holder.layoutPosition)!!.id
adapter.updateItems(0, itemCount - 1, "upSelect")
}
}
binding.ivEdit.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { server ->
showDialogFragment(ServerConfigDialog(server.id))
}
}
binding.ivDelete.setOnClickListener {
alert {
setTitle(R.string.draw)
setMessage(R.string.sure_del)
yesButton {
getItemByLayoutPosition(holder.layoutPosition)?.let { server ->
viewModel.delete(server)
}
}
noButton()
}
}
}
override fun convert(
holder: ItemViewHolder,
binding: ItemServerSelectBinding,
item: Server,
payloads: MutableList<Any>
) {
if (payloads.isEmpty()) {
//binding.root.setBackgroundColor(context.backgroundColor)
binding.rbServer.text = item.name
binding.rbServer.isChecked = item.id == selectServerId
} else {
binding.rbServer.isChecked = item.id == selectServerId
}
}
}
interface Callback {
fun onDialogDismiss(tag: String)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.import.remote
import android.app.Application
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Server
class ServersViewModel(application: Application): BaseViewModel(application) {
fun delete(server: Server) {
execute {
appDb.serverDao.delete(server)
}
}
}
@@ -0,0 +1,787 @@
package io.legato.kazusa.ui.book.info
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.RenderEffect
import android.graphics.Shader
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityBookInfoBinding
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.addType
import io.legato.kazusa.help.book.getRemoteUrl
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isLocalTxt
import io.legato.kazusa.help.book.isWebFile
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.lib.dialogs.selector
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.remote.RemoteBookWebDav
import io.legato.kazusa.ui.about.AppLogDialog
import io.legato.kazusa.ui.book.audio.AudioPlayActivity
import io.legato.kazusa.ui.book.changecover.ChangeCoverDialog
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.group.GroupSelectDialog
import io.legato.kazusa.ui.book.info.edit.BookInfoEditActivity
import io.legato.kazusa.ui.book.manga.ReadMangaActivity
import io.legato.kazusa.ui.book.read.ReadBookActivity
import io.legato.kazusa.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legato.kazusa.ui.book.search.SearchActivity
import io.legato.kazusa.ui.book.source.edit.BookSourceEditActivity
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.login.SourceLoginActivity
import io.legato.kazusa.ui.widget.dialog.PhotoDialog
import io.legato.kazusa.ui.widget.dialog.VariableDialog
import io.legato.kazusa.ui.widget.dialog.WaitDialog
import io.legato.kazusa.utils.ConvertUtils
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.longToastOnUi
import io.legato.kazusa.utils.openFileUri
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.shareWithQr
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.startActivity
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import io.legato.kazusa.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BookInfoActivity :
VMBaseActivity<ActivityBookInfoBinding, BookInfoViewModel>(),
GroupSelectDialog.CallBack,
ChangeBookSourceDialog.CallBack,
ChangeCoverDialog.CallBack,
VariableDialog.Callback {
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
it?.let {
viewModel.getBook(false)?.let { book ->
lifecycleScope.launch {
withContext(IO) {
book.durChapterIndex = it.first
book.durChapterPos = it.second
chapterChanged = it.third
appDb.bookDao.update(book)
}
startReadActivity(book)
}
}
} ?: let {
if (!viewModel.inBookshelf) {
viewModel.delBook()
}
}
}
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
AppConfig.defaultBookTreeUri = treeUri.toString()
}
}
private val readBookResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
viewModel.upBook(intent)
when (it.resultCode) {
RESULT_OK -> {
viewModel.inBookshelf = true
upTvBookshelf()
}
RESULT_DELETED -> {
setResult(RESULT_OK)
finish()
}
}
}
private val infoEditResult = registerForActivityResult(
StartActivityContract(BookInfoEditActivity::class.java)
) {
if (it.resultCode == RESULT_OK) {
viewModel.upEditBook()
}
}
private val editSourceResult = registerForActivityResult(
StartActivityContract(BookSourceEditActivity::class.java)
) {
if (it.resultCode == RESULT_CANCELED) {
return@registerForActivityResult
}
book?.let { book ->
viewModel.bookSource = appDb.bookSourceDao.getBookSource(book.origin)
viewModel.refreshBook(book)
}
}
private var chapterChanged = false
private val waitDialog by lazy { WaitDialog(this) }
private var editMenuItem: MenuItem? = null
private val book get() = viewModel.getBook(false)
override val binding by viewBinding(ActivityBookInfoBinding::inflate)
override val viewModel by viewModels<BookInfoViewModel>()
@SuppressLint("PrivateResource")
override fun onActivityCreated(savedInstanceState: Bundle?) {
setSupportActionBar(binding.topBar)
// binding.refreshLayout?.setColorSchemeColors(accentColor)
// binding.llInfo.setBackgroundColor(backgroundColor)
// binding.flAction.setBackgroundColor(bottomBackground)
//binding.flAction.applyNavigationBarPadding()
binding.btnShelf?.text = getString(R.string.remove_from_bookshelf)
binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
binding.tvDetail?.let { it.revealOnFocusHint = false }
}
viewModel.bookData.observe(this) { showBook(it) }
viewModel.chapterListData.observe(this) { upLoading(false, it) }
viewModel.waitDialogData.observe(this) { upWaitDialogStatus(it) }
viewModel.initData(intent)
initViewEvent()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_info, menu)
editMenuItem = menu.findItem(R.id.menu_edit)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_can_update)?.isChecked =
viewModel.bookData.value?.canUpdate ?: true
menu.findItem(R.id.menu_split_long_chapter)?.isChecked =
viewModel.bookData.value?.getSplitLongChapter() ?: true
menu.findItem(R.id.menu_login)?.isVisible =
!viewModel.bookSource?.loginUrl.isNullOrBlank()
menu.findItem(R.id.menu_set_source_variable)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_set_book_variable)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_can_update)?.isVisible =
viewModel.bookSource != null
menu.findItem(R.id.menu_split_long_chapter)?.isVisible =
viewModel.bookData.value?.isLocalTxt ?: false
menu.findItem(R.id.menu_upload)?.isVisible =
viewModel.bookData.value?.isLocal ?: false
menu.findItem(R.id.menu_delete_alert)?.isChecked =
LocalConfig.bookInfoDeleteAlert
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_edit -> {
viewModel.getBook()?.let {
infoEditResult.launch {
putExtra("bookUrl", it.bookUrl)
}
}
}
R.id.menu_share_it -> {
viewModel.getBook()?.let {
val bookJson = GSON.toJson(it)
val shareStr = "${it.bookUrl}#$bookJson"
shareWithQr(shareStr, it.name)
}
}
R.id.menu_refresh -> {
refreshBook()
}
R.id.menu_login -> viewModel.bookSource?.let {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", it.bookSourceUrl)
}
}
R.id.menu_top -> viewModel.topBook()
R.id.menu_set_source_variable -> setSourceVariable()
R.id.menu_set_book_variable -> setBookVariable()
R.id.menu_copy_book_url -> viewModel.getBook()?.bookUrl?.let {
sendToClip(it)
}
R.id.menu_copy_toc_url -> viewModel.getBook()?.tocUrl?.let {
sendToClip(it)
}
R.id.menu_can_update -> {
viewModel.getBook()?.let {
it.canUpdate = !it.canUpdate
if (viewModel.inBookshelf) {
if (!it.canUpdate) {
it.removeType(BookType.updateError)
}
viewModel.saveBook(it)
}
}
}
R.id.menu_clear_cache -> viewModel.clearCache()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_split_long_chapter -> {
upLoading(true)
viewModel.getBook()?.let {
it.setSplitLongChapter(!item.isChecked)
viewModel.loadBookInfo(it, false)
}
item.isChecked = !item.isChecked
if (!item.isChecked) longToastOnUi(R.string.need_more_time_load_content)
}
R.id.menu_delete_alert -> LocalConfig.bookInfoDeleteAlert = !item.isChecked
R.id.menu_upload -> {
viewModel.getBook()?.let { book ->
book.getRemoteUrl()?.let {
alert(R.string.draw, R.string.sure_upload) {
okButton {
upLoadBook(book)
}
cancelButton()
}
} ?: upLoadBook(book)
}
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun observeLiveBus() {
viewModel.actionLive.observe(this) {
when (it) {
"selectBooksDir" -> localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
}
}
}
}
// override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
// if (ev.action == MotionEvent.ACTION_DOWN) {
// currentFocus?.let {
// if (it === binding.tvIntro && binding.tvIntro.hasSelection()) {
// it.clearFocus()
// }
// }
// }
// return super.dispatchTouchEvent(ev)
// }
private fun refreshBook() {
upLoading(true)
viewModel.getBook()?.let {
viewModel.refreshBook(it)
}
}
private fun upLoadBook(
book: Book,
bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav,
) {
lifecycleScope.launch {
waitDialog.setText("上传中.....")
waitDialog.show()
try {
bookWebDav
?.upload(book)
?: throw NoStackTraceException("未配置webDav")
//更新书籍最后更新时间,使之比远程书籍的时间新
book.lastCheckTime = System.currentTimeMillis()
viewModel.saveBook(book)
} catch (e: Exception) {
toastOnUi(e.localizedMessage)
} finally {
waitDialog.dismiss()
}
}
}
private fun showBook(book: Book) = binding.run {
showCover(book)
tvName.text = book.name
tvAuthor.text = getString(R.string.author_show, book.getRealAuthor())
tvOrigin.text = getString(R.string.origin_show, book.originName)
tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle)
tvDetail?.let { it.text = book.getDisplayIntro() }
llToc?.visible(!book.isWebFile)
upTvBookshelf()
upKinds(book)
upGroup(book.group)
}
private fun upKinds(book: Book) = binding.run {
lifecycleScope.launch {
var kinds = book.getKindList()
if (book.isLocal) {
withContext(IO) {
val size = FileDoc.fromFile(book.bookUrl).size
if (size > 0) {
kinds = kinds.toMutableList()
kinds.add(ConvertUtils.formatFileSize(size))
}
}
}
if (kinds.isEmpty()) {
lbKind.gone()
} else {
lbKind.visible()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
lbKind.setLabels(kinds)
}
}
}
}
private fun showCover(book: Book) {
binding.ivCover.load(book.getDisplayCover(), book.name, book.author, false, book.origin) {
if (!AppConfig.isEInkMode) {
//高版本使用RenderEffect
BookCover.load(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BookCover.load(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
val blurEffect = RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP)
binding.bgBook.setRenderEffect(blurEffect)
}else{
BookCover.loadBlur(this, book.getDisplayCover(), false, book.origin)
.into(binding.bgBook)
}
}
}
}
private fun upLoading(isLoading: Boolean, chapterList: List<BookChapter>? = null) {
when {
isLoading -> {
binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading))
}
chapterList.isNullOrEmpty() -> {
binding.tvToc.text = getString(
R.string.toc_s,
getString(R.string.error_load_toc)
)
}
else -> {
book?.let {
binding.tvToc.text = getString(R.string.toc_s, it.durChapterTitle)
binding.tvLasted.text = getString(R.string.lasted_show, it.latestChapterTitle)
}
}
}
}
private fun upTvBookshelf() {
binding.btnShelf?.apply {
if (viewModel.inBookshelf) {
text = getString(R.string.remove_from_bookshelf)
icon = ContextCompat.getDrawable(context, R.drawable.ic_clear_all)
} else {
text = getString(R.string.add_to_bookshelf)
icon = ContextCompat.getDrawable(context, R.drawable.ic_star)
}
}
editMenuItem?.isVisible = viewModel.inBookshelf
}
private fun upGroup(groupId: Long) {
viewModel.loadGroup(groupId) {
if (it.isNullOrEmpty()) {
binding.tvGroup.text = if (book?.isLocal == true) {
getString(R.string.group_s, getString(R.string.local_no_group))
} else {
getString(R.string.group_s, getString(R.string.no_group))
}
} else {
binding.tvGroup.text = getString(R.string.group_s, it)
}
}
}
private fun initViewEvent() = binding.run {
ivCover.setOnClickListener {
viewModel.getBook()?.let {
showDialogFragment(
ChangeCoverDialog(it.name, it.author)
)
}
}
ivCover.setOnLongClickListener {
viewModel.getBook()?.getDisplayCover()?.let { path ->
showDialogFragment(PhotoDialog(path))
}
true
}
btnRead?.setOnClickListener {
viewModel.getBook()?.let { book ->
if (book.isWebFile) {
showWebFileDownloadAlert {
readBook(it)
}
} else {
readBook(book)
}
}
}
btnShelf?.setOnClickListener {
viewModel.getBook()?.let { book ->
if (viewModel.inBookshelf) {
deleteBook()
} else {
if (book.isWebFile) {
showWebFileDownloadAlert()
} else {
viewModel.addToBookshelf {
upTvBookshelf()
}
}
}
}
}
tvOrigin.setOnClickListener {
viewModel.getBook()?.let { book ->
if (book.isLocal) return@let
if (!appDb.bookSourceDao.has(book.origin)) {
toastOnUi(R.string.error_no_source)
return@let
}
editSourceResult.launch {
putExtra("sourceUrl", book.origin)
}
}
}
btnChangeSource?.setOnClickListener {
viewModel.getBook()?.let { book ->
showDialogFragment(ChangeBookSourceDialog(book.name, book.author))
}
}
tvTocView.setOnClickListener {
if (viewModel.chapterListData.value.isNullOrEmpty()) {
toastOnUi(R.string.chapter_list_empty)
return@setOnClickListener
}
viewModel.getBook()?.let { book ->
if (!viewModel.inBookshelf) {
viewModel.saveBook(book) {
viewModel.saveChapterList {
openChapterList()
}
}
} else {
openChapterList()
}
}
}
tvChangeGroup.setOnClickListener {
viewModel.getBook()?.let {
showDialogFragment(
GroupSelectDialog(it.group)
)
}
}
tvAuthor.setOnClickListener {
viewModel.getBook(false)?.let { book ->
startActivity<SearchActivity> {
putExtra("key", book.author)
}
}
}
tvName.setOnClickListener {
viewModel.getBook(false)?.let { book ->
startActivity<SearchActivity> {
putExtra("key", book.name)
}
}
}
refreshLayout?.setOnRefreshListener {
refreshLayout.isRefreshing = false
refreshBook()
}
}
private fun setSourceVariable() {
lifecycleScope.launch {
val source = viewModel.bookSource
if (source == null) {
toastOnUi("书源不存在")
return@launch
}
val comment =
source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
val variable = withContext(IO) { source.getVariable() }
showDialogFragment(
VariableDialog(
getString(R.string.set_source_variable),
source.getKey(),
variable,
comment
)
)
}
}
private fun setBookVariable() {
lifecycleScope.launch {
val source = viewModel.bookSource
if (source == null) {
toastOnUi("书源不存在")
return@launch
}
val book = viewModel.getBook() ?: return@launch
val variable = withContext(IO) { book.getCustomVariable() }
val comment = source.getDisplayVariableComment(
"""书籍变量可在js中通过book.getVariable("custom")获取"""
)
showDialogFragment(
VariableDialog(
getString(R.string.set_book_variable),
book.bookUrl,
variable,
comment
)
)
}
}
override fun setVariable(key: String, variable: String?) {
when (key) {
viewModel.bookSource?.getKey() -> viewModel.bookSource?.setVariable(variable)
viewModel.bookData.value?.bookUrl -> viewModel.bookData.value?.let {
it.putCustomVariable(variable)
viewModel.saveBook(it)
}
}
}
@SuppressLint("InflateParams")
private fun deleteBook() {
viewModel.getBook()?.let {
if (LocalConfig.bookInfoDeleteAlert) {
alert(
titleResource = R.string.draw,
messageResource = R.string.sure_del
) {
var checkBox: CheckBox? = null
if (it.isLocal) {
checkBox = CheckBox(this@BookInfoActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookInfoActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
}
yesButton {
if (checkBox != null) {
LocalConfig.deleteBookOriginal = checkBox.isChecked
}
viewModel.delBook(LocalConfig.deleteBookOriginal) {
setResult(RESULT_OK)
finish()
}
}
noButton()
}
} else {
viewModel.delBook(LocalConfig.deleteBookOriginal) {
setResult(RESULT_OK)
finish()
}
}
}
}
private fun openChapterList() {
viewModel.getBook()?.let {
tocActivityResult.launch(it.bookUrl)
}
}
private fun showWebFileDownloadAlert(
onClick: ((Book) -> Unit)? = null,
) {
val webFiles = viewModel.webFiles
if (webFiles.isEmpty()) {
toastOnUi("Unexpected webFileData")
return
}
selector(
R.string.download_and_import_file,
webFiles
) { _, webFile, _ ->
if (webFile.isSupported) {
/* import */
viewModel.importOrDownloadWebFile<Book>(webFile) {
onClick?.invoke(it)
}
} else if (webFile.isSupportDecompress) {
/* 解压筛选后再选择导入项 */
viewModel.importOrDownloadWebFile<Uri>(webFile) { uri ->
viewModel.getArchiveFilesName(uri) { fileNames ->
if (fileNames.size == 1) {
viewModel.importArchiveBook(uri, fileNames[0]) {
onClick?.invoke(it)
}
} else {
showDecompressFileImportAlert(uri, fileNames, onClick)
}
}
}
} else {
alert(
title = getString(R.string.draw),
message = getString(R.string.file_not_supported, webFile.name)
) {
neutralButton(R.string.open_fun) {
/* download only */
viewModel.importOrDownloadWebFile<Uri>(webFile) {
openFileUri(it, "*/*")
}
}
noButton()
}
}
}
}
private fun showDecompressFileImportAlert(
archiveFileUri: Uri,
fileNames: List<String>,
success: ((Book) -> Unit)? = null,
) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
return
}
selector(
R.string.import_select_book,
fileNames
) { _, name, _ ->
viewModel.importArchiveBook(archiveFileUri, name) {
success?.invoke(it)
}
}
}
private fun readBook(book: Book) {
if (!viewModel.inBookshelf) {
book.addType(BookType.notShelf)
viewModel.saveBook(book) {
viewModel.saveChapterList {
startReadActivity(book)
}
}
} else {
viewModel.saveBook(book) {
startReadActivity(book)
}
}
}
private fun startReadActivity(book: Book) {
when {
book.isAudio -> readBookResult.launch(
Intent(this, AudioPlayActivity::class.java)
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
)
else -> readBookResult.launch(
Intent(
this,
if (book.isImage && AppConfig.showMangaUi) ReadMangaActivity::class.java
else ReadBookActivity::class.java
)
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
.putExtra("chapterChanged", chapterChanged)
)
}
}
override val oldBook: Book?
get() = viewModel.bookData.value
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
viewModel.changeTo(source, book, toc)
}
override fun coverChangeTo(coverUrl: String) {
viewModel.bookData.value?.let { book ->
book.customCoverUrl = coverUrl
showCover(book)
if (viewModel.inBookshelf) {
viewModel.saveBook(book)
}
}
}
override fun upGroup(requestCode: Int, groupId: Long) {
upGroup(groupId)
viewModel.getBook()?.let { book ->
book.group = groupId
if (viewModel.inBookshelf) {
viewModel.saveBook(book)
} else if (groupId > 0) {
viewModel.addToBookshelf {
upTvBookshelf()
}
}
}
}
private fun upWaitDialogStatus(isShow: Boolean) {
val showText = "Loading....."
if (isShow) {
waitDialog.run {
setText(showText)
show()
}
} else {
waitDialog.dismiss()
}
}
}
@@ -0,0 +1,524 @@
package io.legato.kazusa.ui.book.info
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.exception.NoBooksDirException
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.getExportFileName
import io.legato.kazusa.help.book.getRemoteUrl
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isNotShelf
import io.legato.kazusa.help.book.isSameNameAuthor
import io.legato.kazusa.help.book.isWebFile
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.updateTo
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.lib.webdav.ObjectNotFoundException
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.UrlUtil
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
class BookInfoViewModel(application: Application) : BaseViewModel(application) {
val bookData = MutableLiveData<Book>()
val chapterListData = MutableLiveData<List<BookChapter>>()
val webFiles = mutableListOf<WebFile>()
var inBookshelf = false
var bookSource: BookSource? = null
private var changeSourceCoroutine: Coroutine<*>? = null
val waitDialogData = MutableLiveData<Boolean>()
val actionLive = MutableLiveData<String>()
fun initData(intent: Intent) {
execute {
val name = intent.getStringExtra("name") ?: ""
val author = intent.getStringExtra("author") ?: ""
val bookUrl = intent.getStringExtra("bookUrl") ?: ""
appDb.bookDao.getBook(name, author)?.let {
inBookshelf = !it.isNotShelf
upBook(it)
return@execute
}
if (bookUrl.isNotBlank()) {
appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
upBook(it)
return@execute
}
}
appDb.searchBookDao.getFirstByNameAuthor(name, author)?.toBook()?.let {
upBook(it)
return@execute
}
throw NoStackTraceException("未找到书籍")
}.onError {
AppLog.put(it.localizedMessage, it)
context.toastOnUi(it.localizedMessage)
}
}
fun upBook(intent: Intent) {
execute {
val name = intent.getStringExtra("name") ?: ""
val author = intent.getStringExtra("author") ?: ""
appDb.bookDao.getBook(name, author)?.let { book ->
upBook(book)
}
}
}
private fun upBook(book: Book) {
execute {
bookData.postValue(book)
upCoverByRule(book)
bookSource = if (book.isLocal) null else
appDb.bookSourceDao.getBookSource(book.origin)
if (book.tocUrl.isEmpty() && !book.isLocal) {
loadBookInfo(book, runPreUpdateJs = inBookshelf)
} else {
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
if (chapterList.isNotEmpty()) {
chapterListData.postValue(chapterList)
} else {
loadChapter(book)
}
}
}
}
private fun upCoverByRule(book: Book) {
execute {
if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) {
val coverUrl = BookCover.searchCover(book)
if (coverUrl.isNullOrBlank()) {
return@execute
}
book.customCoverUrl = coverUrl
bookData.postValue(book)
if (inBookshelf) {
saveBook(book)
}
}
}
}
fun refreshBook(book: Book) {
executeLazy(executeContext = IO) {
if (book.isLocal) {
book.tocUrl = ""
book.getRemoteUrl()?.let {
val bookWebDav = AppWebDav.defaultBookWebDav
?: throw NoStackTraceException("webDav没有配置")
val remoteBook = bookWebDav.getRemoteBook(it)
if (remoteBook == null) {
book.origin = BookType.localTag
} else if (remoteBook.lastModify > book.lastCheckTime) {
val uri = bookWebDav.downloadRemoteBook(remoteBook)
book.bookUrl = if (uri.isContentScheme()) uri.toString() else uri.path!!
book.lastCheckTime = remoteBook.lastModify
}
}
} else {
val bs = bookSource ?: return@executeLazy
if (book.originName != bs.bookSourceName) {
book.originName = bs.bookSourceName
}
}
}.onError {
when (it) {
is ObjectNotFoundException -> {
book.origin = BookType.localTag
}
else -> {
AppLog.put("下载远程书籍<${book.name}>失败", it)
}
}
}.onFinally {
loadBookInfo(book, false)
}.start()
}
fun loadBookInfo(
book: Book,
canReName: Boolean = true,
runPreUpdateJs: Boolean = true,
scope: CoroutineScope = viewModelScope
) {
if (book.isLocal) {
LocalBook.upBookInfo(book)
bookData.postValue(book)
loadChapter(book)
} else {
val bookSource = bookSource ?: let {
chapterListData.postValue(emptyList())
context.toastOnUi(R.string.error_no_source)
return
}
WebBook.getBookInfo(scope, bookSource, book, canReName = canReName)
.onSuccess(IO) {
val dbBook = appDb.bookDao.getBook(book.name, book.author)
if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == book.origin) {
/**
* book 来自搜索时(inBookshelf == false),搜索的书名不存在于书架,但是加载详情后,书名更新,存在同名书籍
* 此时 book 的数据会与数据库中的不同,需要更新 #3652 #4619
* book 加载详情后虽然书名作者相同,但是又可能不是数据库中(书源不同)的那本书 #3149
*/
dbBook.updateTo(it)
inBookshelf = true
}
bookData.postValue(it)
if (inBookshelf) {
it.save()
}
if (it.isWebFile) {
loadWebFile(it)
} else {
loadChapter(it, runPreUpdateJs)
}
}.onError {
AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_book_info)
}
}
}
private fun loadChapter(
book: Book,
runPreUpdateJs: Boolean = true,
scope: CoroutineScope = viewModelScope
) {
if (book.isLocal) {
execute(scope) {
LocalBook.getChapterList(book).let {
appDb.bookDao.update(book)
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
ReadBook.onChapterListUpdated(book)
bookData.postValue(book)
chapterListData.postValue(it)
}
}.onError {
context.toastOnUi("LoadTocError:${it.localizedMessage}")
}
} else {
val bookSource = bookSource ?: let {
chapterListData.postValue(emptyList())
context.toastOnUi(R.string.error_no_source)
return
}
val oldBook = book.copy()
WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs)
.onSuccess(IO) {
if (inBookshelf) {
book.save()
/**
* runPreUpdateJs 有可能会修改 book 的 bookUrl
*/
if (oldBook.bookUrl != book.bookUrl) {
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
ReadBook.onChapterListUpdated(book)
}
bookData.postValue(book)
chapterListData.postValue(it)
}.onError {
chapterListData.postValue(emptyList())
AppLog.put("获取目录失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_chapter_list)
}
}
}
fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) {
execute {
appDb.bookGroupDao.getGroupNames(groupId).joinToString(",")
}.onSuccess {
success.invoke(it)
}
}
private fun loadWebFile(book: Book) {
execute {
webFiles.clear()
val fileNameNoExtension = if (book.author.isBlank()) book.name
else "${book.name} 作者:${book.author}"
book.downloadUrls!!.map {
val analyzeUrl = AnalyzeUrl(
it, source = bookSource,
coroutineContext = coroutineContext
)
val mFileName = UrlUtil.getFileName(analyzeUrl)
?: "${fileNameNoExtension}.${analyzeUrl.type}"
WebFile(it, mFileName)
}
}.onError {
context.toastOnUi("LoadWebFileError\n${it.localizedMessage}")
}.onSuccess {
webFiles.addAll(it)
}
}
/* 导入或者下载在线文件 */
fun <T> importOrDownloadWebFile(webFile: WebFile, success: ((T) -> Unit)?) {
bookSource ?: return
execute {
waitDialogData.postValue(true)
if (webFile.isSupported) {
val book = LocalBook.importFileOnLine(
webFile.url,
bookData.value!!.getExportFileName(webFile.suffix),
bookSource
)
changeToLocalBook(book)
} else {
LocalBook.saveBookFile(
webFile.url,
bookData.value!!.getExportFileName(webFile.suffix),
bookSource
)
}
}.onSuccess {
@Suppress("unchecked_cast")
success?.invoke(it as T)
}.onError {
when (it) {
is NoBooksDirException -> actionLive.postValue("selectBooksDir")
else -> {
AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
webFiles.remove(webFile)
}
}
}.onFinally {
waitDialogData.postValue(false)
}
}
fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List<String>) -> Unit) {
execute {
ArchiveUtils.getArchiveFilesName(archiveFileUri) {
AppPattern.bookFileRegex.matches(it)
}
}.onError {
AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
}.onSuccess {
onSuccess.invoke(it)
}
}
fun importArchiveBook(
archiveFileUri: Uri,
archiveEntryName: String,
success: ((Book) -> Unit)? = null
) {
execute {
val suffix = archiveEntryName.substringAfterLast(".")
LocalBook.importArchiveFile(
archiveFileUri,
bookData.value!!.getExportFileName(suffix)
) {
it.contains(archiveEntryName)
}.first()
}.onSuccess {
val book = changeToLocalBook(it)
success?.invoke(book)
}.onError {
AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
}
}
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
bookSource = source
bookData.value?.migrateTo(book, toc)
if (inBookshelf) {
book.removeType(BookType.updateError)
bookData.value?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
bookData.postValue(book)
chapterListData.postValue(toc)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
fun topBook() {
execute {
bookData.value?.let { book ->
val minOrder = appDb.bookDao.minOrder
book.order = minOrder - 1
book.durChapterTime = System.currentTimeMillis()
appDb.bookDao.update(book)
}
}
}
fun saveBook(book: Book?, success: (() -> Unit)? = null) {
book ?: return
execute {
if (book.order == 0) {
book.order = appDb.bookDao.minOrder - 1
}
appDb.bookDao.getBook(book.name, book.author)?.let {
book.durChapterIndex = it.durChapterIndex
book.durChapterPos = it.durChapterPos
book.durChapterTitle = it.durChapterTitle
}
book.save()
if (ReadBook.book?.isSameNameAuthor(book) == true) {
ReadBook.book = book
} else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
AudioPlay.book = book
}
}.onSuccess {
success?.invoke()
}
}
fun saveChapterList(success: (() -> Unit)?) {
execute {
chapterListData.value?.let {
appDb.bookChapterDao.insert(*it.toTypedArray())
}
}.onSuccess {
success?.invoke()
}
}
fun addToBookshelf(success: (() -> Unit)?) {
execute {
bookData.value?.let { book ->
book.removeType(BookType.notShelf)
if (book.order == 0) {
book.order = appDb.bookDao.minOrder - 1
}
appDb.bookDao.getBook(book.name, book.author)?.let {
book.durChapterIndex = it.durChapterIndex
book.durChapterPos = it.durChapterPos
book.durChapterTitle = it.durChapterTitle
}
if (ReadBook.book?.isSameNameAuthor(book) == true) {
ReadBook.book = book
} else if (AudioPlay.book?.isSameNameAuthor(book) == true) {
AudioPlay.book = book
}
book.save()
}
chapterListData.value?.let {
appDb.bookChapterDao.insert(*it.toTypedArray())
}
inBookshelf = true
}.onSuccess {
success?.invoke()
}
}
fun getBook(toastNull: Boolean = true): Book? {
val book = bookData.value
if (toastNull && book == null) {
context.toastOnUi("book is null")
}
return book
}
fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) {
execute {
bookData.value?.let {
it.delete()
inBookshelf = false
if (it.isLocal) {
LocalBook.deleteBook(it, deleteOriginal)
}
}
}.onSuccess {
success?.invoke()
}
}
fun clearCache() {
execute {
BookHelp.clearCache(bookData.value!!)
if (ReadBook.book?.bookUrl == bookData.value!!.bookUrl) {
ReadBook.clearTextChapter()
}
if (ReadManga.book?.bookUrl == bookData.value!!.bookUrl) {
ReadManga.clearMangaChapter()
}
}.onSuccess {
context.toastOnUi(R.string.clear_cache_success)
}.onError {
context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
}
}
fun upEditBook() {
bookData.value?.let {
appDb.bookDao.getBook(it.bookUrl)?.let { book ->
bookData.postValue(book)
}
}
}
private fun changeToLocalBook(localBook: Book): Book {
return LocalBook.mergeBook(localBook, bookData.value).let {
bookData.postValue(it)
loadChapter(it)
inBookshelf = true
it
}
}
data class WebFile(
val url: String,
val name: String,
) {
override fun toString(): String {
return name
}
// 后缀
val suffix: String = UrlUtil.getSuffix(name)
// txt epub umd pdf等文件
val isSupported: Boolean = AppPattern.bookFileRegex.matches(name)
// 压缩包形式的txt epub umd pdf文件
val isSupportDecompress: Boolean = AppPattern.archiveFileRegex.matches(name)
}
}
@@ -0,0 +1,170 @@
package io.legato.kazusa.ui.book.info.edit
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.databinding.ActivityBookInfoEditBinding
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.addType
import io.legato.kazusa.help.book.isAudio
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.ui.book.changecover.ChangeCoverDialog
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.SelectImageContract
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.launch
import io.legato.kazusa.utils.readUri
import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
import io.legato.kazusa.utils.showDialogFragment
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import splitties.init.appCtx
import splitties.views.bottomPadding
import java.io.FileOutputStream
class BookInfoEditActivity :
VMBaseActivity<ActivityBookInfoEditBinding, BookInfoEditViewModel>(),
ChangeCoverDialog.CallBack {
private val selectCover = registerForActivityResult(SelectImageContract()) {
it.uri?.let { uri ->
coverChangeTo(uri)
}
}
override val binding by viewBinding(ActivityBookInfoEditBinding::inflate)
override val viewModel by viewModels<BookInfoEditViewModel>()
override fun onActivityCreated(savedInstanceState: Bundle?) {
viewModel.bookData.observe(this) { upView(it) }
if (viewModel.bookData.value == null) {
intent.getStringExtra("bookUrl")?.let {
viewModel.loadBook(it)
}
}
initView()
initEvent()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_info_edit, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_save -> saveData()
}
return super.onCompatOptionsItemSelected(item)
}
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
}
}
private fun initEvent() = binding.run {
tvChangeCover.setOnClickListener {
viewModel.bookData.value?.let {
showDialogFragment(
ChangeCoverDialog(it.name, it.author)
)
}
}
tvSelectCover.setOnClickListener {
selectCover.launch()
}
tvRefreshCover.setOnClickListener {
viewModel.book?.customCoverUrl = tieCoverUrl.text?.toString()
upCover()
}
}
private fun upView(book: Book) = binding.run {
tieBookName.setText(book.name)
tieBookAuthor.setText(book.author)
spType.setSelection(
when {
book.isImage -> 2
book.isAudio -> 1
else -> 0
}
)
tieCoverUrl.setText(book.getDisplayCover())
tieBookIntro.setText(book.getDisplayIntro())
upCover()
}
private fun upCover() {
viewModel.book?.let {
binding.ivCover.load(it.getDisplayCover(), it.name, it.author, false, it.origin)
}
}
private fun saveData() = binding.run {
val book = viewModel.book ?: return@run
val oldBook = book.copy()
book.name = tieBookName.text?.toString() ?: ""
book.author = tieBookAuthor.text?.toString() ?: ""
val local = if (book.isLocal) BookType.local else 0
val bookType = when (spType.selectedItemPosition) {
2 -> BookType.image or local
1 -> BookType.audio or local
else -> BookType.text or local
}
book.removeType(BookType.local, BookType.image, BookType.audio, BookType.text)
book.addType(bookType)
val customCoverUrl = tieCoverUrl.text?.toString()
book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl
val customIntro = tieBookIntro.text?.toString()
book.customIntro = if (customIntro == book.intro) null else customIntro
BookHelp.updateCacheFolder(oldBook, book)
viewModel.saveBook(book) {
setResult(RESULT_OK)
finish()
}
}
override fun coverChangeTo(coverUrl: String) {
viewModel.book?.customCoverUrl = coverUrl
binding.tieCoverUrl.setText(coverUrl)
upCover()
}
private fun coverChangeTo(uri: Uri) {
readUri(uri) { fileDoc, inputStream ->
runCatching {
inputStream.use {
var file = this.externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = uri.inputStream(this).getOrThrow().use {
MD5Utils.md5Encode(it) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
coverChangeTo(file.absolutePath)
}
}.onFailure {
appCtx.toastOnUi(it.localizedMessage)
}
}
}
}
@@ -0,0 +1,33 @@
package io.legato.kazusa.ui.book.info.edit
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.model.ReadBook
class BookInfoEditViewModel(application: Application) : BaseViewModel(application) {
var book: Book? = null
val bookData = MutableLiveData<Book>()
fun loadBook(bookUrl: String) {
execute {
book = appDb.bookDao.getBook(bookUrl)
book?.let {
bookData.postValue(it)
}
}
}
fun saveBook(book: Book, success: (() -> Unit)?) {
execute {
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.book = book
}
appDb.bookDao.update(book)
}.onSuccess {
success?.invoke()
}
}
}
@@ -0,0 +1,243 @@
package io.legato.kazusa.ui.book.manage
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
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.Book
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.databinding.ItemArrangeBookBinding
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.config.AppConfig
//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.*
class BookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<Book, ItemArrangeBookBinding>(context),
ItemTouchCallback.Callback {
val groupRequestCode = 12
private val selectedBooks: HashSet<Book> = hashSetOf()
var actionItem: Book? = null
val selection: List<Book>
get() {
return getItems().filter {
selectedBooks.contains(it)
}
}
override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding {
return ItemArrangeBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
callBack.upSelectCount()
}
override fun convert(
holder: ItemViewHolder,
binding: ItemArrangeBookBinding,
item: Book,
payloads: MutableList<Any>
) {
binding.apply {
//root.setBackgroundColor(context.backgroundColor)
tvName.text = item.name
tvAuthor.text = item.author
tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE
tvGroupS.text = getGroupName(item.group)
checkbox.isChecked = selectedBooks.contains(item)
if (item.isLocal) {
tvOrigin.setText(R.string.local_book)
} else {
tvOrigin.text = item.originName
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) {
binding.apply {
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
if (buttonView.isPressed) {
if (isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
}
}
root.setOnClickListener {
getItem(holder.layoutPosition)?.let {
checkbox.isChecked = !checkbox.isChecked
if (checkbox.isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
if (AppConfig.openBookInfoByClickTitle) {
tvName.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBook(it)
}
}
}
tvDelete.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.deleteBook(it)
}
}
tvGroup.setOnClickListener {
getItem(holder.layoutPosition)?.let {
actionItem = it
callBack.selectGroup(groupRequestCode, it.group)
}
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
selectedBooks.add(it)
}
} else {
selectedBooks.clear()
}
notifyDataSetChanged()
callBack.upSelectCount()
}
@SuppressLint("NotifyDataSetChanged")
fun revertSelection() {
getItems().forEach {
if (selectedBooks.contains(it)) {
selectedBooks.remove(it)
} else {
selectedBooks.add(it)
}
}
notifyDataSetChanged()
callBack.upSelectCount()
}
fun checkSelectedInterval() {
val selectedPosition = linkedSetOf<Int>()
getItems().forEachIndexed { index, it ->
if (selectedBooks.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 {
selectedBooks.add(it)
}
}
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
}
private fun getGroupList(groupId: Long): List<String> {
val groupNames = arrayListOf<String>()
callBack.groupList.forEach {
if (it.groupId > 0 && it.groupId and groupId > 0) {
groupNames.add(it.groupName)
}
}
return groupNames
}
private fun getGroupName(groupId: Long): String {
val groupNames = getGroupList(groupId)
if (groupNames.isEmpty()) {
return ""
}
return groupNames.joinToString(",")
}
private var isMoved = false
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.order == targetItem.order) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
} else {
val pos = srcItem.order
srcItem.order = targetItem.order
targetItem.order = pos
}
}
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
callBack.updateBook(*getItems().toTypedArray())
}
isMoved = false
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<Book>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<Book> {
return selectedBooks
}
override fun getItemId(position: Int): Book {
return getItem(position)!!
}
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
getItem(position)?.let {
if (isSelected) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
notifyItemChanged(position, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
return true
}
return false
}
}
interface CallBack {
val groupList: List<BookGroup>
fun upSelectCount()
fun updateBook(vararg book: Book)
fun deleteBook(book: Book)
fun selectGroup(requestCode: Int, groupId: Long)
fun openBook(book: Book)
}
}
@@ -0,0 +1,433 @@
package io.legato.kazusa.ui.book.manage
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
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.Book
import io.legato.kazusa.data.entities.BookGroup
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityArrangeBookBinding
import io.legato.kazusa.databinding.DialogEditTextBinding
import io.legato.kazusa.help.DirectLinkUpload
import io.legato.kazusa.help.book.contains
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.LocalConfig
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.book.group.GroupManageDialog
import io.legato.kazusa.ui.book.group.GroupSelectDialog
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.ui.file.HandleFileContract
import io.legato.kazusa.ui.widget.SelectActionBar
import io.legato.kazusa.ui.widget.dialog.WaitDialog
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.cnCompare
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.hideSoftInput
import io.legato.kazusa.utils.isAbsUrl
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.shouldHideSoftInput
import io.legato.kazusa.utils.showDialogFragment
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.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
/**
* 书架管理
*/
class BookshelfManageActivity :
VMBaseActivity<ActivityArrangeBookBinding, BookshelfManageViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
BookAdapter.CallBack,
SourcePickerDialog.Callback,
GroupSelectDialog.CallBack {
override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
override val viewModel by viewModels<BookshelfManageViewModel>()
override val groupList: ArrayList<BookGroup> = arrayListOf()
private val groupRequestCode = 22
private val addToGroupRequestCode = 34
private val adapter by lazy { BookAdapter(this, this) }
private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var books: List<Book>? = null
private val waitDialog by lazy { WaitDialog(this) }
private val exportDir = 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?) {
viewModel.groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
viewModel.groupName = withContext(IO) {
appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName
?: getString(R.string.no_group)
}
upTitle()
}
initSearchView()
initRecyclerView()
initOtherView()
initGroupData()
upBookDataByGroupId()
}
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 observeLiveBus() {
viewModel.batchChangeSourceState.observe(this) {
if (it) {
waitDialog.setText(R.string.change_source_batch)
waitDialog.show()
} else {
waitDialog.dismiss()
}
}
viewModel.batchChangeSourceProcessLiveData.observe(this) {
waitDialog.setText(it)
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bookshelf_manage, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked =
AppConfig.openBookInfoByClickTitle
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
selectGroup(groupRequestCode, 0)
}
private fun upTitle() {
searchView.queryHint = getString(R.string.screen) + " • " + viewModel.groupName
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
upBookData()
return false
}
})
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
// When this page is opened, it is in selection mode
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initOtherView() {
binding.selectActionBar.setMainActionText(R.string.move_to_group)
binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
waitDialog.setOnCancelListener {
viewModel.batchChangeSourceCoroutine?.cancel()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("书架管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun upBookDataByGroupId() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId)
appDb.bookDao.flowByGroup(viewModel.groupId).map { list ->
when (bookSort) {
1 -> list.sortedByDescending {
it.latestChapterTime
}
2 -> list.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> list.sortedBy {
it.order
}
4 -> list.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> list.sortedByDescending {
it.durChapterTime
}
}
}.catch {
AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO)
.conflate().collect {
books = it
upBookData()
itemTouchCallback.isCanDrag = bookSort == 3
}
}
}
private fun upBookData() {
books?.let { books ->
val searchKey = searchView.query
if (searchKey.isNullOrEmpty()) {
adapter.setItems(books)
} else {
books.filter {
it.contains(searchKey.toString())
}.let {
adapter.setItems(it)
}
}
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_open_book_info_by_click_title -> {
AppConfig.openBookInfoByClickTitle = !item.isChecked
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file ->
exportDir.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"bookSource.json",
file,
"application/json"
)
}
}
else -> if (item.groupId == R.id.menu_group) {
viewModel.groupName = item.title.toString()
upTitle()
viewModel.groupId =
appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
upBookDataByGroupId()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_del_selection -> alertDelSelection()
R.id.menu_update_enable ->
viewModel.upCanUpdate(adapter.selection, true)
R.id.menu_update_disable ->
viewModel.upCanUpdate(adapter.selection, false)
R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection)
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
}
return false
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
private fun alertDelSelection() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
val checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
okButton {
LocalConfig.deleteBookOriginal = checkBox.isChecked
viewModel.deleteBook(adapter.selection, checkBox.isChecked)
}
noButton()
}
}
override fun selectGroup(requestCode: Int, groupId: Long) {
showDialogFragment(
GroupSelectDialog(groupId, requestCode)
)
}
override fun upGroup(requestCode: Int, groupId: Long) {
when (requestCode) {
groupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) {
books[it].copy(group = groupId)
}
viewModel.updateBook(*array)
}
adapter.groupRequestCode -> {
adapter.actionItem?.let {
viewModel.updateBook(it.copy(group = groupId))
}
}
addToGroupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) { index ->
val book = books[index]
book.copy(group = book.group or groupId)
}
viewModel.updateBook(*array)
}
}
}
override fun upSelectCount() {
binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size)
}
override fun updateBook(vararg book: Book) {
viewModel.updateBook(*book)
}
override fun deleteBook(book: Book) {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
var checkBox: CheckBox? = null
if (book.isLocal) {
checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
}
okButton {
if (checkBox != null) {
LocalConfig.deleteBookOriginal = checkBox.isChecked
}
viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal)
}
}
}
override fun openBook(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
}
}
override fun sourceOnClick(source: BookSource) {
viewModel.changeSource(adapter.selection, source)
viewModel.batchChangeSourceState.value = true
}
}
@@ -0,0 +1,130 @@
package io.legato.kazusa.ui.book.manage
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeToOutputStream
import kotlinx.coroutines.delay
import java.io.File
class BookshelfManageViewModel(application: Application) : BaseViewModel(application) {
var groupId: Long = -1L
var groupName: String? = null
val batchChangeSourceState = MutableLiveData<Boolean>()
val batchChangeSourceProcessLiveData = MutableLiveData<String>()
var batchChangeSourceCoroutine: Coroutine<Unit>? = null
fun upCanUpdate(books: List<Book>, canUpdate: Boolean) {
execute {
val array = Array(books.size) {
books[it].copy(canUpdate = canUpdate).apply {
if (!canUpdate) {
removeType(BookType.updateError)
}
}
}
appDb.bookDao.update(*array)
}
}
fun updateBook(vararg book: Book) {
execute {
appDb.bookDao.update(*book)
}
}
fun deleteBook(books: List<Book>, deleteOriginal: Boolean = false) {
execute {
appDb.bookDao.delete(*books.toTypedArray())
books.forEach {
if (it.isLocal) {
LocalBook.deleteBook(it, deleteOriginal)
}
}
}
}
fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) {
execute {
val path = "${context.filesDir}/shareBookSource.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
val sources = appDb.bookDao.getAllUseBookSource()
file.outputStream().buffered().use {
GSON.writeToOutputStream(it, sources)
}
file
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi(it.stackTraceStr)
}
}
fun changeSource(books: List<Book>, source: BookSource) {
batchChangeSourceCoroutine?.cancel()
batchChangeSourceCoroutine = execute {
val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L
books.forEachIndexed { index, book ->
batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}")
if (book.isLocal) return@forEachIndexed
if (book.origin == source.bookSourceUrl) return@forEachIndexed
val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
.onFailure {
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
}.getOrNull() ?: return@forEachIndexed
kotlin.runCatching {
if (newBook.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, newBook)
}
}.onFailure {
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
return@forEachIndexed
}
WebBook.getChapterListAwait(source, newBook)
.onFailure {
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
}.getOrNull()?.let { toc ->
book.migrateTo(newBook, toc)
book.removeType(BookType.updateError)
appDb.bookDao.insert(newBook)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
delay(changeSourceDelay)
}
}.onStart {
batchChangeSourceState.postValue(true)
}.onFinally {
batchChangeSourceState.postValue(false)
}
}
fun clearCache(books: List<Book>) {
execute {
books.forEach {
BookHelp.clearCache(it)
}
}.onSuccess {
context.toastOnUi(R.string.clear_cache_success)
}
}
}
@@ -0,0 +1,161 @@
package io.legato.kazusa.ui.book.manage
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
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.constant.AppLog
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.data.entities.BookSourcePart
import io.legato.kazusa.databinding.DialogSourcePickerBinding
import io.legato.kazusa.databinding.Item1lineTextBinding
import io.legato.kazusa.help.config.AppConfig
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legato.kazusa.ui.widget.number.NumberPickerDialog
import io.legato.kazusa.utils.applyTint
import io.legato.kazusa.utils.dpToPx
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import splitties.views.onClick
/**
* 书源选择
*/
class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogSourcePickerBinding::bind)
private val searchView: SearchView by lazy {
binding.toolBar.findViewById(R.id.search_view)
}
private val toolBar: Toolbar by lazy {
binding.toolBar.toolbar
}
private val adapter by lazy {
SourceAdapter(requireContext())
}
private var sourceFlowJob: Job? = null
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initData()
initMenu()
}
private fun initView() {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = "选择书源"
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search_book_source)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initData(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = lifecycleScope.launch {
when {
searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
}.catch {
AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun initMenu() {
toolBar.setOnMenuItemClickListener(this)
toolBar.inflateMenu(R.menu.source_picker)
toolBar.menu.applyTint(requireContext())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_change_source_delay -> NumberPickerDialog(requireContext())
.setTitle(getString(R.string.change_source_delay))
.setMaxValue(9999)
.setMinValue(0)
.setValue(AppConfig.batchChangeSourceDelay)
.show {
AppConfig.batchChangeSourceDelay = it
}
}
return true
}
inner class SourceAdapter(context: Context) :
RecyclerAdapter<BookSourcePart, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false).apply {
root.setPadding(16.dpToPx())
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: BookSourcePart,
payloads: MutableList<Any>
) {
binding.textView.text = item.getDisPlayNameGroup()
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
it.getBookSource()?.let { source ->
callback?.sourceOnClick(source)
}
dismissAllowingStateLoss()
}
}
}
}
private val callback: Callback?
get() {
return (parentFragment as? Callback) ?: activity as? Callback
}
interface Callback {
fun sourceOnClick(source: BookSource)
}
}
@@ -0,0 +1,788 @@
package io.legato.kazusa.ui.book.manga
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.WindowManager
import android.view.animation.LinearInterpolator
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import com.bumptech.glide.Glide
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import com.bumptech.glide.util.FixedPreloadSizeProvider
import io.legato.kazusa.BuildConfig
import io.legato.kazusa.R
import io.legato.kazusa.base.VMBaseActivity
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.databinding.ActivityMangaBinding
import io.legato.kazusa.databinding.ViewLoadMoreBinding
import io.legato.kazusa.help.book.isImage
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.storage.Backup
import io.legato.kazusa.lib.dialogs.alert
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.receiver.NetworkChangedListener
import io.legato.kazusa.ui.book.changesource.ChangeBookSourceDialog
import io.legato.kazusa.ui.book.info.BookInfoActivity
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterConfig
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterDialog
import io.legato.kazusa.ui.book.manga.config.MangaFooterConfig
import io.legato.kazusa.ui.book.manga.config.MangaFooterSettingDialog
import io.legato.kazusa.ui.book.manga.entities.BaseMangaPage
import io.legato.kazusa.ui.book.manga.entities.MangaPage
import io.legato.kazusa.ui.book.manga.recyclerview.MangaAdapter
import io.legato.kazusa.ui.book.manga.recyclerview.MangaLayoutManager
import io.legato.kazusa.ui.book.manga.recyclerview.ScrollTimer
import io.legato.kazusa.ui.book.read.MangaMenu
import io.legato.kazusa.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legato.kazusa.ui.book.toc.TocActivityResult
import io.legato.kazusa.ui.widget.number.NumberPickerDialog
import io.legato.kazusa.ui.widget.recycler.LoadMoreView
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.StartActivityContract
import io.legato.kazusa.utils.canScroll
import io.legato.kazusa.utils.fastBinarySearch
import io.legato.kazusa.utils.findCenterViewPosition
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.getCompatColor
import io.legato.kazusa.utils.gone
import io.legato.kazusa.utils.observeEvent
import io.legato.kazusa.utils.showDialogFragment
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.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DecimalFormat
import kotlin.math.ceil
class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewModel>(),
ReadManga.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack,
MangaColorFilterDialog.Callback, ScrollTimer.ScrollCallback {
private val mLayoutManager by lazy {
MangaLayoutManager(this)
}
private val mAdapter: MangaAdapter by lazy {
MangaAdapter(this)
}
private val mSizeProvider by lazy {
FixedPreloadSizeProvider<Any>(resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
}
private val mPagerSnapHelper: PagerSnapHelper by lazy {
PagerSnapHelper()
}
private lateinit var mMangaFooterConfig: MangaFooterConfig
private val mLabelBuilder by lazy { StringBuilder() }
private var mMenu: Menu? = null
private var mRecyclerViewPreloader: RecyclerViewPreloader<Any>? = null
private val networkChangedListener by lazy {
NetworkChangedListener(this)
}
private var justInitData: Boolean = false
private var syncDialog: AlertDialog? = null
private val mScrollTimer by lazy {
ScrollTimer(this, binding.recyclerView, lifecycleScope).apply {
setSpeed(AppConfig.mangaAutoPageSpeed)
}
}
private var enableAutoScrollPage = false
private var enableAutoScroll = false
private val mLinearInterpolator by lazy {
LinearInterpolator()
}
private val loadMoreView by lazy {
LoadMoreView(this).apply {
setBackgroundColor(getCompatColor(R.color.book_ant_10))
setLoadingColor(R.color.white)
setLoadingTextColor(R.color.white)
}
}
//打开目录返回选择章节返回结果
private val tocActivity = registerForActivityResult(TocActivityResult()) {
it?.let {
viewModel.openChapter(it.first, it.second)
}
}
private val bookInfoActivity =
registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
setResult(RESULT_DELETED)
super.finish()
} else {
ReadManga.loadOrUpContent()
}
}
override val binding by viewBinding(ActivityMangaBinding::inflate)
override val viewModel by viewModels<ReadMangaViewModel>()
private val loadingViewVisible get() = binding.flLoading.isVisible
private val df by lazy {
DecimalFormat("0.0%")
}
override fun onCreate(savedInstanceState: Bundle?) {
upLayoutInDisplayCutoutMode()
super.onCreate(savedInstanceState)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
ReadManga.register(this)
upSystemUiVisibility(false)
initRecyclerView()
binding.tvRetry.setOnClickListener {
binding.llLoading.isVisible = true
binding.llRetry.isGone = true
ReadManga.loadOrUpContent()
}
mAdapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading && ReadManga.hasNextChapter) {
loadMoreView.startLoad()
ReadManga.loadOrUpContent()
}
}
loadMoreView.gone()
mMangaFooterConfig =
GSON.fromJsonObject<MangaFooterConfig>(AppConfig.mangaFooterConfig).getOrNull()
?: MangaFooterConfig()
}
override fun observeLiveBus() {
observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
mMangaFooterConfig = it
val item = mAdapter.getItem(binding.recyclerView.findCenterViewPosition())
upInfoBar(item)
}
}
private fun initRecyclerView() {
val mangaColorFilter =
GSON.fromJsonObject<MangaColorFilterConfig>(AppConfig.mangaColorFilter).getOrNull()
?: MangaColorFilterConfig()
mAdapter.run {
setMangaImageColorFilter(mangaColorFilter)
}
setHorizontalScroll(AppConfig.enableMangaHorizontalScroll)
binding.recyclerView.run {
adapter = mAdapter
itemAnimator = null
layoutManager = mLayoutManager
setHasFixedSize(true)
setDisableClickScroll(AppConfig.disableClickScroll)
setDisableMangaScale(AppConfig.disableMangaScale)
setRecyclerViewPreloader(AppConfig.mangaPreDownloadNum)
setPreScrollListener { _, _, _, position ->
if (mAdapter.isNotEmpty()) {
val item = mAdapter.getItem(position)
if (item is BaseMangaPage) {
if (ReadManga.durChapterIndex < item.chapterIndex) {
ReadManga.moveToNextChapter()
} else if (ReadManga.durChapterIndex > item.chapterIndex) {
ReadManga.moveToPrevChapter()
} else {
ReadManga.durChapterPos = item.index
ReadManga.curPageChanged()
}
if (item is MangaPage) {
binding.mangaMenu.upSeekBar(item.index, item.imageCount)
upInfoBar(item)
}
}
}
}
}
binding.webtoonFrame.run {
onTouchMiddle {
if (!binding.mangaMenu.isVisible && !loadingViewVisible) {
binding.mangaMenu.runMenuIn()
}
}
onNextPage {
scrollToNext()
}
onPrevPage {
scrollToPrev()
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
viewModel.initData(intent)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Looper.myQueue().addIdleHandler {
viewModel.initData(intent)
false
}
justInitData = true
}
override fun upContent() {
lifecycleScope.launch {
setTitle(ReadManga.book?.name)
val data = withContext(IO) { ReadManga.mangaContents }
val pos = data.pos
val list = data.items
val curFinish = data.curFinish
val nextFinish = data.nextFinish
mAdapter.submitList(list) {
if (loadingViewVisible && curFinish) {
binding.infobar.isVisible = true
upInfoBar(list[pos])
mLayoutManager.scrollToPositionWithOffset(pos, 0)
binding.flLoading.isGone = true
loadMoreView.visible()
binding.mangaMenu.upSeekBar(
ReadManga.durChapterPos, ReadManga.curMangaChapter!!.imageCount
)
}
if (curFinish) {
if (!ReadManga.hasNextChapter) {
loadMoreView.noMore("暂无章节了!")
} else if (nextFinish) {
loadMoreView.stopLoad()
} else {
loadMoreView.startLoad()
}
}
}
}
}
private fun upInfoBar(page: Any?) {
if (page !is MangaPage) {
return
}
val chapterIndex = page.chapterIndex
val chapterSize = page.chapterSize
val chapterPos = page.index
val imageCount = page.imageCount
val chapterName = page.mChapterName
mMangaFooterConfig.run {
mLabelBuilder.clear()
binding.infobar.isGone = hideFooter
binding.infobar.textInfoAlignment = footerOrientation
if (!hideChapterName) {
mLabelBuilder.append(chapterName).append(" ")
}
if (!hidePageNumber) {
if (!hidePageNumberLabel) {
mLabelBuilder.append(getString(R.string.manga_check_page_number))
}
mLabelBuilder.append("${chapterPos + 1}/${imageCount}").append(" ")
}
if (!hideChapter) {
if (!hideChapterLabel) {
mLabelBuilder.append(getString(R.string.manga_check_chapter))
}
mLabelBuilder.append("${chapterIndex + 1}/${chapterSize}").append(" ")
}
if (!hideProgressRatio) {
if (!hideProgressRatioLabel) {
mLabelBuilder.append(getString(R.string.manga_check_progress))
}
val percent = if (chapterSize == 0 || imageCount == 0 && chapterIndex == 0) {
"0.0%"
} else if (imageCount == 0) {
df.format((chapterIndex + 1.0f) / chapterSize.toDouble())
} else {
var percent =
df.format(
chapterIndex * 1.0f / chapterSize + 1.0f /
chapterSize * (chapterPos + 1) / imageCount.toDouble()
)
if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || chapterPos + 1 != imageCount)) {
percent = "99.9%"
}
percent
}
mLabelBuilder.append(percent)
}
}
binding.infobar.update(
if (mLabelBuilder.isEmpty()) "" else mLabelBuilder.toString()
)
}
override fun onResume() {
super.onResume()
networkChangedListener.register()
networkChangedListener.onNetworkChanged = {
// 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑)
if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData) {
ReadManga.syncProgress({ progress -> sureNewProgress(progress) })
}
}
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = true
}
if (enableAutoScroll) {
mScrollTimer.isEnabled = true
}
}
override fun onPause() {
super.onPause()
if (ReadManga.inBookshelf) {
ReadManga.saveRead()
if (!BuildConfig.DEBUG) {
if (AppConfig.syncBookProgressPlus) {
ReadManga.syncProgress()
} else {
ReadManga.uploadProgress()
}
Backup.autoBack(this)
}
}
ReadManga.cancelPreDownloadTask()
networkChangedListener.unRegister()
mScrollTimer.isEnabledPage = false
mScrollTimer.isEnabled = false
}
override fun loadFail(msg: String) {
lifecycleScope.launch {
if (loadingViewVisible) {
binding.llLoading.isGone = true
binding.llRetry.isVisible = true
binding.tvMsg.text = msg
} else {
loadMoreView.error(null, "加载失败,点击重试")
}
}
}
override fun onDestroy() {
ReadManga.unregister(this)
super.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).clearMemory()
}
override fun sureNewProgress(progress: BookProgress) {
syncDialog?.dismiss()
syncDialog = alert(R.string.get_book_progress) {
setMessage(R.string.cloud_progress_exceeds_current)
okButton {
ReadManga.setProgress(progress)
}
noButton()
}
}
override fun showLoading() {
lifecycleScope.launch {
binding.flLoading.isVisible = true
}
}
override fun startLoad() {
lifecycleScope.launch {
loadMoreView.startLoad()
}
}
override fun scrollBy(distance: Int) {
if (!binding.recyclerView.canScroll(1)) {
return
}
val time = ceil(16f / distance * 10000).toInt()
binding.recyclerView.smoothScrollBy(10000, 10000, mLinearInterpolator, time)
}
override fun scrollPage() {
scrollToNext()
}
override val oldBook: Book?
get() = ReadManga.book
override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
if (book.isImage) {
binding.flLoading.isVisible = true
viewModel.changeTo(book, toc)
} else {
toastOnUi("所选择的源不是漫画源")
}
}
override fun updateColorFilter(config: MangaColorFilterConfig) {
mAdapter.setMangaImageColorFilter(config)
updateWindowBrightness(config.l)
}
@SuppressLint("StringFormatMatches")
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_manga, menu)
upMenu(menu)
return super.onCompatCreateOptionsMenu(menu)
}
/**
* 菜单
*/
@SuppressLint("StringFormatMatches", "NotifyDataSetChanged")
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_change_source -> {
binding.mangaMenu.runMenuOut()
ReadManga.book?.let {
showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
}
}
R.id.menu_catalog -> {
ReadManga.book?.let {
tocActivity.launch(it.bookUrl)
}
}
R.id.menu_refresh -> {
binding.flLoading.isVisible = true
ReadManga.book?.let {
viewModel.refreshContentDur(it)
}
}
R.id.menu_pre_manga_number -> {
showNumberPickerDialog(
0,
getString(R.string.pre_download),
AppConfig.mangaPreDownloadNum
) {
AppConfig.mangaPreDownloadNum = it
item.title = getString(R.string.pre_download_m, it)
setRecyclerViewPreloader(it)
}
}
R.id.menu_disable_manga_scale -> {
item.isChecked = !item.isChecked
AppConfig.disableMangaScale = item.isChecked
setDisableMangaScale(item.isChecked)
}
R.id.menu_disable_click_scroll -> {
item.isChecked = !item.isChecked
AppConfig.disableClickScroll = item.isChecked
setDisableClickScroll(item.isChecked)
}
R.id.menu_enable_auto_page -> {
item.isChecked = !item.isChecked
val menuMangaAutoPageSpeed = mMenu?.findItem(R.id.menu_manga_auto_page_speed)
mScrollTimer.isEnabledPage = item.isChecked
menuMangaAutoPageSpeed?.isVisible = item.isChecked
enableAutoScrollPage = item.isChecked
enableAutoScroll = false
mScrollTimer.isEnabled = false
mMenu?.findItem(R.id.menu_enable_auto_scroll)?.isChecked = false
}
R.id.menu_manga_auto_page_speed -> {
showNumberPickerDialog(
1, getString(R.string.setting_manga_auto_page_speed),
AppConfig.mangaAutoPageSpeed
) {
AppConfig.mangaAutoPageSpeed = it
item.title = getString(R.string.manga_auto_page_speed, it)
mScrollTimer.setSpeed(it)
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = true
}
}
}
R.id.menu_manga_footer_config -> {
showDialogFragment(MangaFooterSettingDialog())
}
R.id.menu_enable_horizontal_scroll -> {
item.isChecked = !item.isChecked
AppConfig.enableMangaHorizontalScroll = item.isChecked
setHorizontalScroll(item.isChecked)
mAdapter.notifyDataSetChanged()
}
R.id.menu_manga_color_filter -> {
binding.mangaMenu.runMenuOut()
showDialogFragment(MangaColorFilterDialog())
}
R.id.menu_enable_auto_scroll -> {
item.isChecked = !item.isChecked
mScrollTimer.isEnabled = item.isChecked
mMenu?.findItem(R.id.menu_enable_auto_page)?.isChecked = false
enableAutoScroll = item.isChecked
enableAutoScrollPage = false
mScrollTimer.isEnabledPage = false
mMenu?.findItem(R.id.menu_manga_auto_page_speed)?.isVisible = item.isChecked
if (enableAutoScroll) {
mPagerSnapHelper.attachToRecyclerView(null)
} else if (AppConfig.enableMangaHorizontalScroll) {
mPagerSnapHelper.attachToRecyclerView(binding.recyclerView)
}
}
R.id.menu_hide_manga_title -> {
item.isChecked = !item.isChecked
AppConfig.hideMangaTitle = item.isChecked
ReadManga.loadContent()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun openBookInfoActivity() {
ReadManga.book?.let {
bookInfoActivity.launch {
putExtra("name", it.name)
putExtra("author", it.author)
}
}
}
override fun upSystemUiVisibility(menuIsVisible: Boolean) {
toggleSystemBar(menuIsVisible)
if (enableAutoScroll) {
mScrollTimer.isEnabled = !menuIsVisible
}
if (enableAutoScrollPage) {
mScrollTimer.isEnabledPage = !menuIsVisible
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val keyCode = event.keyCode
val action = event.action
val isDown = action == 0
if (keyCode == KeyEvent.KEYCODE_MENU) {
if (isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.runMenuIn()
return true
}
if (!isDown && !binding.mangaMenu.canShowMenu) {
binding.mangaMenu.canShowMenu = true
return true
}
}
return super.dispatchKeyEvent(event)
}
private fun setRecyclerViewPreloader(maxPreload: Int) {
if (mRecyclerViewPreloader != null) {
binding.recyclerView.removeOnScrollListener(mRecyclerViewPreloader!!)
}
mRecyclerViewPreloader = RecyclerViewPreloader(
Glide.with(this), mAdapter, mSizeProvider, maxPreload
)
binding.recyclerView.addOnScrollListener(mRecyclerViewPreloader!!)
}
private fun setHorizontalScroll(enable: Boolean) {
mAdapter.isHorizontal = enable
if (enable) {
if (!enableAutoScroll) {
mPagerSnapHelper.attachToRecyclerView(binding.recyclerView)
}
mLayoutManager.orientation = LinearLayoutManager.HORIZONTAL
} else {
mPagerSnapHelper.attachToRecyclerView(null)
mLayoutManager.orientation = LinearLayoutManager.VERTICAL
}
}
@SuppressLint("StringFormatMatches")
private fun upMenu(menu: Menu) {
this.mMenu = menu
menu.findItem(R.id.menu_pre_manga_number).title =
getString(R.string.pre_download_m, AppConfig.mangaPreDownloadNum)
menu.findItem(R.id.menu_disable_manga_scale).isChecked = AppConfig.disableMangaScale
menu.findItem(R.id.menu_disable_click_scroll).isChecked = AppConfig.disableClickScroll
menu.findItem(R.id.menu_manga_auto_page_speed).title =
getString(R.string.manga_auto_page_speed, AppConfig.mangaAutoPageSpeed)
menu.findItem(R.id.menu_enable_horizontal_scroll).isChecked =
AppConfig.enableMangaHorizontalScroll
menu.findItem(R.id.menu_hide_manga_title).isChecked = AppConfig.hideMangaTitle
}
private fun setDisableMangaScale(disable: Boolean) {
binding.webtoonFrame.disableMangaScale = disable
binding.recyclerView.disableMangaScale = disable
if (disable) {
binding.recyclerView.resetZoom()
}
}
private fun setDisableClickScroll(disable: Boolean) {
binding.webtoonFrame.disabledClickScroll = disable
}
private fun upLayoutInDisplayCutoutMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
private fun scrollToNext() {
scrollPageTo(1)
}
private fun scrollToPrev() {
scrollPageTo(-1)
}
private fun scrollPageTo(direction: Int) {
if (!binding.recyclerView.canScroll(direction)) {
return
}
var dx = 0
var dy = 0
if (AppConfig.enableMangaHorizontalScroll) {
dx = binding.recyclerView.run {
width - paddingStart - paddingEnd
}
} else {
dy = binding.recyclerView.run {
height - paddingTop - paddingBottom
}
}
dx *= direction
dy *= direction
binding.recyclerView.smoothScrollBy(dx, dy)
}
private fun showNumberPickerDialog(
min: Int,
title: String,
initValue: Int,
callback: (Int) -> Unit,
) {
NumberPickerDialog(this)
.setTitle(title)
.setMaxValue(9999)
.setMinValue(min)
.setValue(initValue)
.show {
callback.invoke(it)
}
}
override fun finish() {
val book = ReadManga.book ?: return super.finish()
if (ReadManga.inBookshelf) {
return super.finish()
}
if (!AppConfig.showAddToShelfAlert) {
viewModel.removeFromBookshelf { super.finish() }
} else {
alert(title = getString(R.string.add_to_bookshelf)) {
setMessage(getString(R.string.check_add_bookshelf, book.name))
okButton {
ReadManga.book?.removeType(BookType.notShelf)
ReadManga.book?.save()
ReadManga.inBookshelf = true
setResult(RESULT_OK)
}
noButton { viewModel.removeFromBookshelf { super.finish() } }
}
}
}
fun updateWindowBrightness(brightness: Int) {
val layoutParams = window.attributes
val normalizedBrightness = brightness.toFloat() / 255.0f
layoutParams.screenBrightness = normalizedBrightness.coerceIn(0f, 1f)
window.attributes = layoutParams
// 强制刷新屏幕
window.decorView.postInvalidate()
}
override fun skipToPage(index: Int) {
val durChapterIndex = ReadManga.durChapterIndex
val itemPos = mAdapter.getItems().fastBinarySearch {
val chapterIndex: Int
val pageIndex: Int
if (it is BaseMangaPage) {
chapterIndex = it.chapterIndex
pageIndex = it.index
} else {
error("unknown item type")
}
val delta = chapterIndex - durChapterIndex
if (delta != 0) {
delta
} else {
pageIndex - index
}
}
if (itemPos > -1) {
mLayoutManager.scrollToPositionWithOffset(itemPos, 0)
upInfoBar(mAdapter.getItem(itemPos))
ReadManga.durChapterPos = index
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
scrollToPrev()
return true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
scrollToNext()
return true
}
}
return super.onKeyDown(keyCode, event)
}
}
@@ -0,0 +1,290 @@
package io.legato.kazusa.ui.book.manga
import android.app.Application
import android.content.Intent
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseViewModel
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookProgress
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.isLocal
import io.legato.kazusa.help.book.isLocalModified
import io.legato.kazusa.help.book.removeType
import io.legato.kazusa.help.book.simulatedTotalChapterNum
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.utils.mapParallelSafe
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.take
import splitties.init.appCtx
class ReadMangaViewModel(application: Application) : BaseViewModel(application) {
private var changeSourceCoroutine: Coroutine<*>? = null
/**
* 初始化
*/
fun initData(intent: Intent, success: (() -> Unit)? = null) {
execute {
ReadManga.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
ReadManga.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
val bookUrl = intent.getStringExtra("bookUrl")
val book = when {
bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
else -> appDb.bookDao.getBook(bookUrl)
} ?: ReadManga.book
when {
book != null -> initManga(book)
else -> context.getString(R.string.no_book)//没有找到书
}
}.onSuccess {
success?.invoke()
}.onError {
val msg = "初始化数据失败\n${it.localizedMessage}"
AppLog.put(msg, it)
}.onFinally {
ReadManga.saveRead()
}
}
private suspend fun initManga(book: Book) {
val isSameBook = ReadManga.book?.bookUrl == book.bookUrl
if (isSameBook) {
ReadManga.upData(book)
} else {
ReadManga.resetData(book)
}
if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
return
}
if (book.isLocal && !checkLocalBookFileExist(book)) {
return
}
if ((ReadManga.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
return
}
//开始加载内容
if (!isSameBook) {
ReadManga.loadContent()
} else {
ReadManga.loadOrUpContent()
}
if (ReadManga.chapterChanged) {
// 有章节跳转不同步阅读进度
ReadManga.chapterChanged = false
} else if (!isSameBook) {
if (AppConfig.syncBookProgressPlus) {
ReadManga.syncProgress(
{ progress -> ReadManga.mCallback?.sureNewProgress(progress) })
} else {
syncBookProgress(book)
}
}
//自动换源
if (!book.isLocal && ReadManga.bookSource == null) {
autoChangeSource(book.name, book.author)
return
}
}
private suspend fun loadChapterListAwait(book: Book): Boolean {
ReadManga.bookSource?.let {
val oldBook = book.copy()
WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
if (oldBook.bookUrl == book.bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.insert(book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(oldBook.bookUrl)
appDb.bookChapterDao.insert(*cList.toTypedArray())
ReadManga.onChapterListUpdated(book)
return true
}.onFailure {
//加载章节出错
ReadManga.mCallback?.loadFail(appCtx.getString(R.string.error_load_toc))
return false
}
}
return true
}
/**
* 加载详情页
*/
private suspend fun loadBookInfo(book: Book): Boolean {
val source = ReadManga.bookSource ?: return true
try {
WebBook.getBookInfoAwait(source, book, canReName = false)
return true
} catch (e: Throwable) {
ReadManga.mCallback?.loadFail("详情页出错: ${e.localizedMessage}")
return false
}
}
/**
* 自动换源
*/
private fun autoChangeSource(name: String, author: String) {
if (!AppConfig.autoChangeSource) return
execute {
val sources = appDb.bookSourceDao.allTextEnabledPart
flow {
for (source in sources) {
source.getBookSource()?.let {
emit(it)
}
}
}.onStart {
// 自动换源
}.mapParallelSafe(AppConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
val chapter = toc.getOrElse(book.durChapterIndex) {
toc.last()
}
val nextChapter = toc.getOrElse(chapter.index) {
toc.first()
}
WebBook.getContentAwait(
bookSource = source,
book = book,
bookChapter = chapter,
nextChapterUrl = nextChapter.url
)
book to toc
}.take(1).onEach { (book, toc) ->
changeTo(book, toc)
}.onEmpty {
throw NoStackTraceException("没有合适书源")
}.onCompletion {
// 换源完成
}.catch {
AppLog.put("自动换源失败\n${it.localizedMessage}", it)
context.toastOnUi("自动换源失败\n${it.localizedMessage}")
}.collect()
}
}
/**
* 同步进度
*/
fun syncBookProgress(
book: Book,
alertSync: ((progress: BookProgress) -> Unit)? = null
) {
if (!AppConfig.syncBookProgress) return
execute {
AppWebDav.getBookProgress(book)
}.onError {
AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it)
}.onSuccess { progress ->
progress ?: return@onSuccess
if (progress.durChapterIndex < book.durChapterIndex ||
(progress.durChapterIndex == book.durChapterIndex
&& progress.durChapterPos < book.durChapterPos)
) {
alertSync?.invoke(progress)
} else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) {
ReadManga.setProgress(progress)
AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}")
}
}
}
/**
* 换源
*/
fun changeTo(book: Book, toc: List<BookChapter>) {
changeSourceCoroutine?.cancel()
changeSourceCoroutine = execute {
//换源中
ReadManga.book?.migrateTo(book, toc)
book.removeType(BookType.updateError)
ReadManga.book?.delete()
appDb.bookDao.insert(book)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadManga.resetData(book)
ReadManga.loadContent()
}.onError {
AppLog.put("换源失败\n$it", it, true)
}.onFinally {
postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
}
}
private fun checkLocalBookFileExist(book: Book): Boolean {
try {
LocalBook.getBookInputStream(book)
return true
} catch (e: Throwable) {
return false
}
}
fun openChapter(index: Int, durChapterPos: Int = 0) {
if (index < ReadManga.chapterSize) {
ReadManga.showLoading()
ReadManga.durChapterIndex = index
ReadManga.durChapterPos = durChapterPos
ReadManga.saveRead()
ReadManga.loadContent()
}
}
fun removeFromBookshelf(success: (() -> Unit)?) {
val book = ReadManga.book
Coroutine.async {
book?.delete()
}.onSuccess {
success?.invoke()
}
}
override fun onCleared() {
super.onCleared()
changeSourceCoroutine?.cancel()
}
fun refreshContentDur(book: Book) {
execute {
appDb.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)
?.let { chapter ->
BookHelp.delContent(book, chapter)
openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos)
}
}
}
}
@@ -0,0 +1,18 @@
package io.legato.kazusa.ui.book.manga.config
import io.legato.kazusa.utils.GSON
data class MangaColorFilterConfig(
var r: Int = 0,
var g: Int = 0,
var b: Int = 0,
var a: Int = 0,
var l: Int = 0
) {
fun toJson(): String {
if (r == 0 && g == 0 && b == 0 && a == 0 && l == 0) {
return ""
}
return GSON.toJson(this)
}
}
@@ -0,0 +1,79 @@
package io.legato.kazusa.ui.book.manga.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseDialogFragment
import io.legato.kazusa.databinding.DialogMangaColorFilterBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class MangaColorFilterDialog : BaseDialogFragment(R.layout.dialog_manga_color_filter) {
private val binding by viewBinding(DialogMangaColorFilterBinding::bind)
private val mConfig =
GSON.fromJsonObject<MangaColorFilterConfig>(AppConfig.mangaColorFilter).getOrNull()
?: MangaColorFilterConfig()
private val callback get() = activity as? Callback
override fun onStart() {
super.onStart()
dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initData()
initView()
}
private fun initData() {
binding.run {
dsbBrightness.progress = mConfig.l
dsbR.progress = mConfig.r
dsbG.progress = mConfig.g
dsbB.progress = mConfig.b
dsbA.progress = mConfig.a
}
}
private fun initView() {
binding.run {
dsbBrightness.onChanged = {
mConfig.l = it
callback?.updateColorFilter(mConfig)
}
dsbR.onChanged = {
mConfig.r = it
callback?.updateColorFilter(mConfig)
}
dsbG.onChanged = {
mConfig.g = it
callback?.updateColorFilter(mConfig)
}
dsbB.onChanged = {
mConfig.b = it
callback?.updateColorFilter(mConfig)
}
dsbA.onChanged = {
mConfig.a = it
callback?.updateColorFilter(mConfig)
}
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaColorFilter = mConfig.toJson()
}
interface Callback {
fun updateColorFilter(config: MangaColorFilterConfig)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.manga.config
import androidx.annotation.Keep
import io.legato.kazusa.ui.widget.ReaderInfoBarView
@Keep
data class MangaFooterConfig(
var hideChapterLabel: Boolean = false,
var hideChapter: Boolean = false,
var hidePageNumberLabel: Boolean = false,
var hidePageNumber: Boolean = false,
var hideProgressRatioLabel: Boolean = false,
var hideProgressRatio: Boolean = false,
var footerOrientation: Int = ReaderInfoBarView.ALIGN_LEFT,//默认靠左
var hideFooter: Boolean = false,
var hideChapterName:Boolean=false,
)
@@ -0,0 +1,112 @@
package io.legato.kazusa.ui.book.manga.config
import android.content.DialogInterface
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.constant.EventBus
import io.legato.kazusa.databinding.DialogMangaFooterSettingBinding
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.ui.widget.ReaderInfoBarView
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.setLayout
import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
class MangaFooterSettingDialog : BaseDialogFragment(R.layout.dialog_manga_footer_setting) {
val config = GSON.fromJsonObject<MangaFooterConfig>(AppConfig.mangaFooterConfig).getOrNull()
?: MangaFooterConfig()
private val binding by viewBinding(DialogMangaFooterSettingBinding::bind)
override fun onStart() {
super.onStart()
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.cbChapterLabel.run {
isChecked = config.hideChapterLabel
setOnCheckedChangeListener { _, isChecked ->
config.hideChapterLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbChapter.run {
isChecked = config.hideChapter
setOnCheckedChangeListener { _, isChecked ->
config.hideChapter = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbPageNumberLabel.run {
isChecked = config.hidePageNumberLabel
setOnCheckedChangeListener { _, isChecked ->
config.hidePageNumberLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbPageNumber.run {
isChecked = config.hidePageNumber
setOnCheckedChangeListener { _, isChecked ->
config.hidePageNumber = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbProgressRatioLabel.run {
isChecked = config.hideProgressRatioLabel
setOnCheckedChangeListener { _, isChecked ->
config.hideProgressRatioLabel = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbProgressRatio.run {
isChecked = config.hideProgressRatio
setOnCheckedChangeListener { _, isChecked ->
config.hideProgressRatio = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.cbChapterName.run {
isChecked = config.hideChapterName
setOnCheckedChangeListener { _, isChecked ->
config.hideChapterName = isChecked
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
binding.rgFooterOrientation.check(if (config.footerOrientation == ReaderInfoBarView.ALIGN_CENTER) R.id.rb_center else R.id.rb_left)
binding.rgFooterOrientation.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_left -> {
config.footerOrientation = ReaderInfoBarView.ALIGN_LEFT
}
R.id.rb_center -> {
config.footerOrientation = ReaderInfoBarView.ALIGN_CENTER
}
}
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
binding.rgFooter.check(if (config.hideFooter) R.id.rb_hide else R.id.rb_show)
binding.rgFooter.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_show -> {
config.hideFooter = false
}
R.id.rb_hide -> {
config.hideFooter = true
}
}
postEvent(EventBus.UP_MANGA_CONFIG, config)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaFooterConfig = GSON.toJson(config)
}
}
@@ -0,0 +1,6 @@
package io.legato.kazusa.ui.book.manga.entities
interface BaseMangaPage {
val chapterIndex: Int
val index: Int
}
@@ -0,0 +1,9 @@
package io.legato.kazusa.ui.book.manga.entities
import io.legato.kazusa.data.entities.BookChapter
data class MangaChapter(
val chapter: BookChapter,
val pages: List<BaseMangaPage>,
val imageCount: Int
)
@@ -0,0 +1,8 @@
package io.legato.kazusa.ui.book.manga.entities
data class MangaContent(
val pos: Int,
val items: List<Any>,
val curFinish: Boolean,
val nextFinish: Boolean
)
@@ -0,0 +1,10 @@
package io.legato.kazusa.ui.book.manga.entities
data class MangaPage(
override val chapterIndex: Int = 0,//总章节位置
val chapterSize: Int,//总章节数量
val mImageUrl: String = "",//当前URL
override val index: Int = 0,//当前章节位置
var imageCount: Int = 0,//当前章节内容总数
val mChapterName: String = "",//章节名称
) : BaseMangaPage
@@ -0,0 +1,9 @@
package io.legato.kazusa.ui.book.manga.entities
data class ReaderLoading(
override val chapterIndex: Int = 0,
override val index: Int = 0,
val mMessage: String? = null,
val isVolume: Boolean = false
) : BaseMangaPage
@@ -0,0 +1,64 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener,
) : GestureDetector(context, listener) {
private val handler = Handler(Looper.getMainLooper())
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
open class Listener : SimpleOnGestureListener() {
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}
@@ -0,0 +1,247 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.annotation.IntRange
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader.PreloadModelProvider
import com.bumptech.glide.RequestBuilder
import io.legato.kazusa.base.adapter.ItemViewHolder
import io.legato.kazusa.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW
import io.legato.kazusa.databinding.ItemBookMangaEdgeBinding
import io.legato.kazusa.databinding.ItemBookMangaPageBinding
import io.legato.kazusa.help.glide.progress.ProgressManager
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.ui.book.manga.config.MangaColorFilterConfig
import io.legato.kazusa.ui.book.manga.entities.MangaPage
import io.legato.kazusa.ui.book.manga.entities.ReaderLoading
import io.legato.kazusa.utils.dpToPx
class MangaAdapter(private val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>(), PreloadModelProvider<Any> {
private val inflater: LayoutInflater = LayoutInflater.from(context)
private lateinit var mConfig: MangaColorFilterConfig
companion object {
private const val LOADING_VIEW = 0
private const val CONTENT_VIEW = 1
}
var isHorizontal = false
private val mDiffCallback: DiffUtil.ItemCallback<Any> = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
newItem.mMessage == oldItem.mMessage
} else if (oldItem is MangaPage && newItem is MangaPage) {
oldItem.mImageUrl == newItem.mImageUrl
} else false
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
oldItem == newItem
} else if (oldItem is MangaPage && newItem is MangaPage) {
oldItem == newItem
} else false
}
}
private val mDiffer = AsyncListDiffer(this, mDiffCallback)
fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList.getOrNull(position)
fun getItems() = mDiffer.currentList
fun isEmpty() = mDiffer.currentList.isEmpty()
fun isNotEmpty() = !isEmpty()
//全部替换数据
fun submitList(contents: List<Any>, runnable: Runnable? = null) {
mDiffer.submitList(contents, runnable)
}
inner class PageViewHolder(binding: ItemBookMangaPageBinding) :
MangaVH<ItemBookMangaPageBinding>(binding, context) {
init {
initComponent(
binding.loading,
binding.image,
binding.progress,
binding.retry,
binding.flProgress
)
binding.retry.setOnClickListener {
val item = mDiffer.currentList[layoutPosition]
if (item is MangaPage) {
val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
loadImageWithRetry(
item.mImageUrl, isHorizontal, isLastImage
)
}
}
}
fun onBind(item: MangaPage) {
setImageColorFilter()
val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
loadImageWithRetry(item.mImageUrl, isHorizontal, isLastImage)
}
fun setImageColorFilter() {
require(
mConfig.r in 0..255 &&
mConfig.g in 0..255 &&
mConfig.b in 0..255 &&
mConfig.a in 0..255
) {
"ARGB values must be between 0-255"
}
val matrix = floatArrayOf(
(255 - mConfig.r) / 255f, 0f, 0f, 0f, 0f,
0f, (255 - mConfig.g) / 255f, 0f, 0f, 0f,
0f, 0f, (255 - mConfig.b) / 255f, 0f, 0f,
0f, 0f, 0f, (255 - mConfig.a) / 255f, 0f
)
binding.image.colorFilter = ColorMatrixColorFilter(ColorMatrix(matrix))
}
}
inner class PageMoreViewHolder(val binding: ItemBookMangaEdgeBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: ReaderLoading) {
val message = item.mMessage
binding.text.text = message
itemView.updateLayoutParams {
height = if (item.isVolume) {
MATCH_PARENT
} else {
96.dpToPx()
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when {
viewType >= TYPE_FOOTER_VIEW -> {
ItemViewHolder(footerItems.get(viewType).invoke(parent))
}
viewType == LOADING_VIEW -> {
PageMoreViewHolder(ItemBookMangaEdgeBinding.inflate(inflater, parent, false))
}
viewType == CONTENT_VIEW -> {
PageViewHolder(ItemBookMangaPageBinding.inflate(inflater, parent, false))
}
else -> error("Unknown view type!")
}
}
override fun getItemCount(): Int = getActualItemCount() + getFooterCount()
override fun getItemViewType(position: Int): Int {
return when {
isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount()
getItem(position) is MangaPage -> CONTENT_VIEW
getItem(position) is ReaderLoading -> LOADING_VIEW
else -> error("Unknown view type!")
}
}
fun getFooterCount() = footerItems.size()
private fun isFooter(position: Int) = position >= getActualItemCount()
override fun onViewRecycled(vh: RecyclerView.ViewHolder) {
super.onViewRecycled(vh)
when (vh) {
is PageViewHolder -> {
vh.itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = MATCH_PARENT
}
Glide.with(context).clear(vh.binding.image)
if (vh.binding.image.tag is String) {
ProgressManager.removeListener(vh.binding.image.tag as String)
}
}
}
}
override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) {
when (vh) {
is PageViewHolder -> vh.onBind(getItem(position) as MangaPage)
is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading)
}
}
private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
@Synchronized
fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = getActualItemCount() + footerItems.size()
footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer)
notifyItemInserted(index)
}
}
/**
* 除去header和footer
*/
fun getActualItemCount() = getItems().size
@Synchronized
fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
kotlin.runCatching {
val index = footerItems.indexOfValue(footer)
if (index >= 0) {
footerItems.remove(index)
notifyItemRemoved(getActualItemCount() + index - 2)
}
}
}
override fun getPreloadItems(position: Int): List<Any> {
if (isEmpty() || position >= getItems().size) {
return emptyList()
}
return getItems().subList(position, position + 1)
}
override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? {
if (item is MangaPage) {
return BookCover.preloadManga(
context,
item.mImageUrl,
sourceOrigin = ReadManga.book?.origin,
)
}
return null
}
fun setMangaImageColorFilter(config: MangaColorFilterConfig) {
mConfig = config
notifyItemRangeChanged(0, itemCount)
}
}
@@ -0,0 +1,17 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class MangaLayoutManager(context: Context) :
LinearLayoutManager(context) {
private val extraLayoutSpace = context.resources.displayMetrics.heightPixels * 3 / 4
@Deprecated("Deprecated in Java")
override fun getExtraLayoutSpace(state: RecyclerView.State?): Int {
return extraLayoutSpace
}
}
@@ -0,0 +1,133 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import io.legato.kazusa.help.glide.progress.ProgressManager
import io.legato.kazusa.model.BookCover
import io.legato.kazusa.model.ReadManga
import io.legato.kazusa.utils.printOnDebug
open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Context) :
RecyclerView.ViewHolder(binding.root) {
protected lateinit var mLoading: ProgressBar
protected lateinit var mImage: AppCompatImageView
protected lateinit var mProgress: TextView
protected lateinit var mFlProgress: FrameLayout
protected var mRetry: Button? = null
private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
fun initComponent(
loading: ProgressBar,
image: AppCompatImageView,
progress: TextView,
button: Button? = null,
flProgress: FrameLayout,
) {
mLoading = loading
mImage = image
mRetry = button
mProgress = progress
mFlProgress = flProgress
}
@SuppressLint("CheckResult")
fun loadImageWithRetry(imageUrl: String, isHorizontal: Boolean, isLastImage: Boolean) {
mFlProgress.isVisible = true
mLoading.isVisible = true
mRetry?.isGone = true
mProgress.isVisible = true
ProgressManager.removeListener(imageUrl)
ProgressManager.addListener(imageUrl) { _, percentage, _, _ ->
@SuppressLint("SetTextI18n")
mProgress.text = "$percentage%"
}
try {
mImage.tag = imageUrl
BookCover.loadManga(
context,
imageUrl,
sourceOrigin = ReadManga.book?.origin,
).addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isVisible = true
mLoading.isGone = true
mRetry?.isVisible = true
mProgress.isGone = true
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean,
): Boolean {
mFlProgress.isGone = true
if (!isHorizontal) {
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
gravity = Gravity.NO_GRAVITY
}
if (isLastImage) {
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
itemView.minimumHeight = minHeight
} else {
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
itemView.minimumHeight = 0
}
mImage.scaleType = ImageView.ScaleType.FIT_XY
} else {
itemView.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
itemView.minimumHeight = 0
mImage.updateLayoutParams<FrameLayout.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
gravity = Gravity.CENTER
}
mImage.scaleType = ImageView.ScaleType.FIT_CENTER
}
return false
}
}).into(mImage)
} catch (e: Exception) {
e.printOnDebug()
}
}
}
@@ -0,0 +1,75 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class ScrollTimer(
private val callback: ScrollCallback,
private val recyclerView: RecyclerView,
private val coroutineScope: CoroutineScope,
) : RecyclerView.OnScrollListener() {
private var distance = 1
private var mScrollPageJob: Job? = null
var isEnabled: Boolean = false
set(value) {
if (field != value) {
field = value
if (value) {
recyclerView.addOnScrollListener(this)
startScroll()
} else {
recyclerView.removeOnScrollListener(this)
recyclerView.stopScroll()
}
}
}
var isEnabledPage: Boolean = false
set(value) {
if (field != value) {
field = value
if (value) {
mScrollPageJob?.cancel()
startScrollPage()
} else {
mScrollPageJob?.cancel()
}
}
}
override fun onScrollStateChanged(
recyclerView: RecyclerView,
newState: Int,
) {
if (newState == SCROLL_STATE_IDLE) {
startScroll()
}
}
fun setSpeed(distance: Int) {
this.distance = distance
}
private fun startScroll() {
callback.scrollBy(distance)
}
private fun startScrollPage() {
mScrollPageJob = coroutineScope.launch {
while (isActive) {
delay(distance.times(1000L))
callback.scrollPage()
}
}
}
interface ScrollCallback {
fun scrollBy(distance: Int)
fun scrollPage()
}
}
@@ -0,0 +1,126 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.content.Context
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.FrameLayout
class WebtoonFrame : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
context,
attrs,
defStyle
)
private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
private val flingDetector = GestureDetector(context, FlingListener())
var doubleTapZoom = true
set(value) {
field = value
recycler?.doubleTapZoom = value
scaleDetector.isQuickScaleEnabled = value
}
var disableMangaScale = false
private val recycler: WebtoonRecyclerView?
get() = getChildAt(0) as? WebtoonRecyclerView
private val mcRect = RectF()
private val blRect = RectF()
private val brRect = RectF()
private var mTouchMiddle: (() -> Unit)? = null
fun onTouchMiddle(init: () -> Unit) = apply { this.mTouchMiddle = init }
private var mNextPage: (() -> Unit)? = null
fun onNextPage(init: () -> Unit) = apply { this.mNextPage = init }
private var mPrevPage: (() -> Unit)? = null
fun onPrevPage(init: () -> Unit) = apply { this.mPrevPage = init }
var disabledClickScroll = false
override fun onAttachedToWindow() {
super.onAttachedToWindow()
recycler?.tapListener = { ev ->
when {
mcRect.contains(ev.rawX, ev.rawY) -> {
mTouchMiddle?.invoke()
}
blRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> {
mPrevPage?.invoke()
}
brRect.contains(ev.rawX, ev.rawY) && !disabledClickScroll -> {
mNextPage?.invoke()
}
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat())
brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat())
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (!disableMangaScale) {
scaleDetector.onTouchEvent(ev)
flingDetector.onTouchEvent(ev)
val recyclerRect = Rect()
recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev)
recyclerRect.inset(1, 1)
if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) {
return super.dispatchTouchEvent(ev)
}
ev.setLocation(
ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()),
ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat()),
)
}
return super.dispatchTouchEvent(ev)
}
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
recycler?.onScale(detector.scaleFactor)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
recycler?.onScaleEnd()
}
}
inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
}
}
}
@@ -0,0 +1,372 @@
package io.legato.kazusa.ui.book.manga.recyclerview
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legato.kazusa.utils.findCenterViewPosition
import kotlin.math.abs
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : RecyclerView(context, attrs, defStyle) {
private var isZooming = false
private var atLastPosition = false
private var atFirstPosition = false
private var halfWidth = 0
private var halfHeight = 0
private var originalHeight = 0
private var heightSet = false
private var firstVisibleItemPosition = 0
private var lastVisibleItemPosition = 0
private var currentScale = DEFAULT_RATE
private var mLastCenterViewPosition = 0
private var mPreScrollListener: IComicPreScroll? = null
private var mNestedPreScrollListener: IComicPreScroll? = null
private val listener = GestureListener()
private val detector = Detector()
var doubleTapZoom = true
var tapListener: ((MotionEvent) -> Unit)? = null
var longTapListener: ((MotionEvent) -> Boolean)? = null
var disableMangaScale = false
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
halfWidth = MeasureSpec.getSize(widthSpec) / 2
halfHeight = MeasureSpec.getSize(heightSpec) / 2
if (!heightSet) {
originalHeight = MeasureSpec.getSize(heightSpec)
heightSet = true
}
super.onMeasure(widthSpec, heightSpec)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent): Boolean {
return detector.onTouchEvent(e) || super.onTouchEvent(e)
}
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
val layoutManager = layoutManager
lastVisibleItemPosition =
(layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
}
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
val layoutManager = layoutManager
val visibleItemCount = layoutManager?.childCount ?: 0
val totalItemCount = layoutManager?.itemCount ?: 0
atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
atFirstPosition = firstVisibleItemPosition == 0
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?,
type: Int
): Boolean {
val position = findCenterViewPosition()
if (position != NO_POSITION && position != mLastCenterViewPosition) {
mLastCenterViewPosition = position
mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
}
mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
}
private fun getPositionX(positionX: Float): Float {
if (currentScale < 1) {
return 0f
}
val maxPositionX = halfWidth * (currentScale - 1)
return positionX.coerceIn(-maxPositionX, maxPositionX)
}
private fun getPositionY(positionY: Float): Float {
if (currentScale < 1) {
return (originalHeight / 2 - halfHeight).toFloat()
}
val maxPositionY = halfHeight * (currentScale - 1)
return positionY.coerceIn(-maxPositionY, maxPositionY)
}
private fun zoom(
fromRate: Float,
toRate: Float,
fromX: Float,
toX: Float,
fromY: Float,
toY: Float,
) {
isZooming = true
val animatorSet = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
scaleAnimator.addUpdateListener { animation ->
currentScale = animation.animatedValue as Float
setScaleRate(currentScale)
}
animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
animatorSet.doOnEnd {
isZooming = false
currentScale = toRate
}
}
fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
if (currentScale <= 1f) return false
val distanceTimeFactor = 0.4f
val animatorSet = AnimatorSet()
if (velocityX != 0) {
val dx = (distanceTimeFactor * velocityX / 2)
val newX = getPositionX(x + dx)
val translationXAnimator = ValueAnimator.ofFloat(x, newX)
translationXAnimator.addUpdateListener { animation ->
x = getPositionX(animation.animatedValue as Float)
}
animatorSet.play(translationXAnimator)
}
if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
val dy = (distanceTimeFactor * velocityY / 2)
val newY = getPositionY(y + dy)
val translationYAnimator = ValueAnimator.ofFloat(y, newY)
translationYAnimator.addUpdateListener { animation ->
y = getPositionY(animation.animatedValue as Float)
}
animatorSet.play(translationYAnimator)
}
animatorSet.duration = 400
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
return true
}
fun resetZoom() {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
}
private fun zoomScrollBy(dx: Int, dy: Int) {
if (dx != 0) {
x = getPositionX(x + dx)
}
if (dy != 0) {
y = getPositionY(y + dy)
}
}
private fun setScaleRate(rate: Float) {
scaleX = rate
scaleY = rate
}
fun onScale(scaleFactor: Float) {
currentScale *= scaleFactor
currentScale = currentScale.coerceIn(
MIN_RATE,
MAX_SCALE_RATE,
)
setScaleRate(currentScale)
layoutParams.height = if (currentScale < 1) {
(originalHeight / currentScale).toInt()
} else {
originalHeight
}
halfHeight = layoutParams.height / 2
if (currentScale != DEFAULT_RATE) {
x = getPositionX(x)
y = getPositionY(y)
} else {
x = 0f
y = 0f
}
requestLayout()
}
fun onScaleBegin() {
if (detector.isDoubleTapping) {
detector.isQuickScaling = true
}
}
fun onScaleEnd() {
if (scaleX < MIN_RATE) {
zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
}
}
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return false
}
override fun onDoubleTap(ev: MotionEvent): Boolean {
detector.isDoubleTapping = true
return false
}
fun onDoubleTapConfirmed(ev: MotionEvent) {
if (!isZooming && doubleTapZoom) {
if (scaleX != DEFAULT_RATE) {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
} else {
val toScale = 2f
val toX = (halfWidth - ev.x) * (toScale - 1)
val toY = (halfHeight - ev.y) * (toScale - 1)
zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
}
}
}
override fun onLongTapConfirmed(ev: MotionEvent) {
if (longTapListener?.invoke(ev) == true) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
inner class Detector : GestureDetectorWithLongTap(context, listener) {
private var scrollPointerId = 0
private var downX = 0
private var downY = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var isZoomDragging = false
var isDoubleTapping = false
var isQuickScaling = false
override fun onTouchEvent(ev: MotionEvent): Boolean {
val action = ev.actionMasked
val actionIndex = ev.actionIndex
when (action) {
MotionEvent.ACTION_DOWN -> {
scrollPointerId = ev.getPointerId(0)
downX = (ev.x + 0.5f).toInt()
downY = (ev.y + 0.5f).toInt()
}
MotionEvent.ACTION_POINTER_DOWN -> {
scrollPointerId = ev.getPointerId(actionIndex)
downX = (ev.getX(actionIndex) + 0.5f).toInt()
downY = (ev.getY(actionIndex) + 0.5f).toInt()
}
MotionEvent.ACTION_MOVE -> {
if (disableMangaScale) {
return super.onTouchEvent(ev)
}
if (isDoubleTapping && isQuickScaling) {
return true
}
val index = ev.findPointerIndex(scrollPointerId)
if (index < 0) {
return false
}
val x = (ev.getX(index) + 0.5f).toInt()
val y = (ev.getY(index) + 0.5f).toInt()
var dx = x - downX
var dy = if (atFirstPosition || atLastPosition) y - downY else 0
if (!isZoomDragging && currentScale > 1f) {
var startScroll = false
if (abs(dx) > touchSlop) {
if (dx < 0) {
dx += touchSlop
} else {
dx -= touchSlop
}
startScroll = true
}
if (abs(dy) > touchSlop) {
if (dy < 0) {
dy += touchSlop
} else {
dy -= touchSlop
}
startScroll = true
}
if (startScroll) {
isZoomDragging = true
}
}
if (isZoomDragging) {
zoomScrollBy(dx, dy)
}
}
MotionEvent.ACTION_UP -> {
if (isDoubleTapping && !isQuickScaling && !disableMangaScale) {
listener.onDoubleTapConfirmed(ev)
}
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
MotionEvent.ACTION_CANCEL -> {
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
}
return super.onTouchEvent(ev)
}
}
fun setPreScrollListener(iComicPreScroll: IComicPreScroll) {
mPreScrollListener = iComicPreScroll
}
fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) {
mNestedPreScrollListener = iComicPreScroll
}
fun interface IComicPreScroll {
fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int)
}
}
private const val ANIMATOR_DURATION_TIME = 200
private const val MIN_RATE = 0.5f
private const val DEFAULT_RATE = 1f
private const val MAX_SCALE_RATE = 3f
Loaded 100 of 972 files, more files were not shown because too many files have changed in this diff. Show more