Rename
This commit is contained in:
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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+205
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+46
@@ -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
|
||||
|
||||
+64
@@ -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
Reference in new issue
Block a user